class PersistentCourseGrade(TimeStampedModel): """ A django model tracking persistent course grades. .. no_pii: """ class Meta: app_label = "grades" # Indices: # (course_id, user_id) for individual grades # (course_id) for instructors to see all course grades, implicitly created via the unique_together constraint # (user_id) for course dashboard; explicitly declared as an index below # (passed_timestamp, course_id) for tracking when users first earned a passing grade. # (modified): find all the grades updated within a certain timespan # (modified, course_id): find all the grades updated within a certain timespan for a course unique_together = [ ('course_id', 'user_id'), ] index_together = [('passed_timestamp', 'course_id'), ('modified', 'course_id')] # primary key will need to be large for this table id = UnsignedBigIntAutoField(primary_key=True) # pylint: disable=invalid-name user_id = models.IntegerField(blank=False, db_index=True) course_id = CourseKeyField(blank=False, max_length=255) # Information relating to the state of content when grade was calculated course_edited_timestamp = models.DateTimeField( 'Last content edit timestamp', blank=True, null=True) course_version = models.CharField('Course content version identifier', blank=True, max_length=255) grading_policy_hash = models.CharField('Hash of grading policy', blank=False, max_length=255) # Information about the course grade itself percent_grade = models.FloatField(blank=False) letter_grade = models.CharField('Letter grade for course', blank=False, max_length=255) # Information related to course completion passed_timestamp = models.DateTimeField( 'Date learner earned a passing grade', blank=True, null=True) _CACHE_NAMESPACE = "grades.models.PersistentCourseGrade" def __str__(self): """ Returns a string representation of this model. """ return ', '.join([ "{} user: {}".format(type(self).__name__, self.user_id), f"course version: {self.course_version}", f"grading policy: {self.grading_policy_hash}", f"percent grade: {self.percent_grade}%", f"letter grade: {self.letter_grade}", f"passed timestamp: {self.passed_timestamp}", ]) @classmethod def prefetch(cls, course_id, users): """ Prefetches grades for the given users for the given course. """ get_cache(cls._CACHE_NAMESPACE)[cls._cache_key(course_id)] = { grade.user_id: grade for grade in cls.objects.filter( user_id__in=[user.id for user in users], course_id=course_id) } @classmethod def clear_prefetched_data(cls, course_key): """ Clears prefetched grades for this course from the RequestCache. """ get_cache(cls._CACHE_NAMESPACE).pop(cls._cache_key(course_key), None) @classmethod def read(cls, user_id, course_id): """ Reads a grade from database Arguments: user_id: The user associated with the desired grade course_id: The id of the course associated with the desired grade Raises PersistentCourseGrade.DoesNotExist if applicable """ try: prefetched_grades = get_cache( cls._CACHE_NAMESPACE)[cls._cache_key(course_id)] try: return prefetched_grades[user_id] except KeyError: # user's grade is not in the prefetched dict, so # assume they have no grade raise cls.DoesNotExist # lint-amnesty, pylint: disable=raise-missing-from except KeyError: # grades were not prefetched for the course, so fetch it return cls.objects.get(user_id=user_id, course_id=course_id) @classmethod def update_or_create(cls, user_id, course_id, **kwargs): """ Creates a course grade in the database. Returns a PersistedCourseGrade object. """ passed = kwargs.pop('passed') if kwargs.get('course_version', None) is None: kwargs['course_version'] = "" grade, _ = cls.objects.update_or_create(user_id=user_id, course_id=course_id, defaults=kwargs) if passed and not grade.passed_timestamp: grade.passed_timestamp = now() grade.save() cls._emit_grade_calculated_event(grade) cls._update_cache(course_id, user_id, grade) return grade @classmethod def _update_cache(cls, course_id, user_id, grade): course_cache = get_cache(cls._CACHE_NAMESPACE).get( cls._cache_key(course_id)) if course_cache is not None: course_cache[user_id] = grade @classmethod def _cache_key(cls, course_id): return f"grades_cache.{course_id}" @staticmethod def _emit_grade_calculated_event(grade): events.course_grade_calculated(grade)
class CourseEmail(Email): """ Stores information for an email to a course. .. no_pii: """ class Meta: app_label = "bulk_email" course_id = CourseKeyField(max_length=255, db_index=True) # to_option is deprecated and unused, but dropping db columns is hard so it's still here for legacy reasons to_option = models.CharField(max_length=64, choices=[("deprecated", "deprecated")]) targets = models.ManyToManyField(Target) template_name = models.CharField(null=True, max_length=255) from_addr = models.CharField(null=True, max_length=255) def __str__(self): return self.subject @classmethod def create(cls, course_id, sender, targets, subject, html_message, text_message=None, template_name=None, from_addr=None): """ Create an instance of CourseEmail. """ # automatically generate the stripped version of the text from the HTML markup: if text_message is None: text_message = html_to_text(html_message) new_targets = [] for target in targets: # split target, to handle cohort:cohort_name and track:mode_slug target_split = target.split(':', 1) # Ensure our desired target exists if target_split[0] not in EMAIL_TARGETS: # lint-amnesty, pylint: disable=no-else-raise fmt = 'Course email being sent to unrecognized target: "{target}" for "{course}", subject "{subject}"' msg = fmt.format(target=target, course=course_id, subject=subject).encode('utf8') raise ValueError(msg) elif target_split[0] == SEND_TO_COHORT: # target_split[1] will contain the cohort name cohort = CohortTarget.ensure_valid_cohort( target_split[1], course_id) new_target, _ = CohortTarget.objects.get_or_create( target_type=target_split[0], cohort=cohort) elif target_split[0] == SEND_TO_TRACK: # target_split[1] contains the desired mode slug CourseModeTarget.ensure_valid_mode(target_split[1], course_id) # There could exist multiple CourseModes that match this query, due to differing currency types. # The currencies do not affect user lookup though, so we can just use the first result. mode = CourseMode.objects.filter(course_id=course_id, mode_slug=target_split[1])[0] new_target, _ = CourseModeTarget.objects.get_or_create( target_type=target_split[0], track=mode) else: new_target, _ = Target.objects.get_or_create( target_type=target_split[0]) new_targets.append(new_target) # create the task, then save it immediately: course_email = cls( course_id=course_id, sender=sender, subject=subject, html_message=html_message, text_message=text_message, template_name=template_name, from_addr=from_addr, ) course_email.save( ) # Must exist in db before setting M2M relationship values course_email.targets.add(*new_targets) course_email.save() return course_email def get_template(self): """ Returns the corresponding CourseEmailTemplate for this CourseEmail. """ return CourseEmailTemplate.get_template(name=self.template_name)
class CourseDiscussionSettings(models.Model): """ Settings for course discussions .. no_pii: """ course_id = CourseKeyField( unique=True, max_length=255, db_index=True, help_text="Which course are these settings associated with?", ) discussions_id_map = JSONField( null=True, blank=True, help_text="Key/value store mapping discussion IDs to discussion XBlock usage keys.", ) always_divide_inline_discussions = models.BooleanField(default=False) reported_content_email_notifications = models.BooleanField(default=False) _divided_discussions = models.TextField(db_column='divided_discussions', null=True, blank=True) # JSON list COHORT = 'cohort' ENROLLMENT_TRACK = 'enrollment_track' NONE = 'none' ASSIGNMENT_TYPE_CHOICES = ((NONE, 'None'), (COHORT, 'Cohort'), (ENROLLMENT_TRACK, 'Enrollment Track')) division_scheme = models.CharField(max_length=20, choices=ASSIGNMENT_TYPE_CHOICES, default=NONE) class Meta: # use existing table that was originally created from django_comment_common app db_table = 'django_comment_common_coursediscussionsettings' @property def divided_discussions(self): """ Jsonify the divided_discussions """ return json.loads(self._divided_discussions) @divided_discussions.setter def divided_discussions(self, value): """ Un-Jsonify the divided_discussions """ self._divided_discussions = json.dumps(value) @request_cached() @classmethod def get(cls, course_key): """ Get and/or create settings """ try: course_discussion_settings = cls.objects.get(course_id=course_key) except cls.DoesNotExist: from openedx.core.djangoapps.course_groups.cohorts import get_legacy_discussion_settings legacy_discussion_settings = get_legacy_discussion_settings(course_key) course_discussion_settings, _ = cls.objects.get_or_create( course_id=course_key, defaults={ 'always_divide_inline_discussions': legacy_discussion_settings['always_cohort_inline_discussions'], 'divided_discussions': legacy_discussion_settings['cohorted_discussions'], 'division_scheme': cls.COHORT if legacy_discussion_settings['is_cohorted'] else cls.NONE }, ) return course_discussion_settings def update(self, validated_data: dict): """ Set discussion settings for a course Returns: A CourseDiscussionSettings object """ fields = { 'division_scheme': (str,)[0], 'always_divide_inline_discussions': bool, 'divided_discussions': list, 'reported_content_email_notifications': bool, } for field, field_type in fields.items(): if field in validated_data: if not isinstance(validated_data[field], field_type): raise ValueError(f"Incorrect field type for `{field}`. Type must be `{field_type.__name__}`") setattr(self, field, validated_data[field]) self.save() return self
class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='SurveyAnswer', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('field_name', models.CharField(max_length=255, db_index=True)), ('field_value', models.CharField(max_length=1024)), ('course_key', CourseKeyField(max_length=255, null=True, db_index=True)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='SurveyForm', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('name', models.CharField(unique=True, max_length=255, db_index=True)), ('form', models.TextField()), ], options={ 'abstract': False, }, ), migrations.AddField( model_name='surveyanswer', name='form', field=models.ForeignKey(to='survey.SurveyForm', on_delete=models.CASCADE), ), migrations.AddField( model_name='surveyanswer', name='user', field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), ), ]
class Bookmark(TimeStampedModel): """ Bookmarks model. .. no_pii: """ user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) course_key = CourseKeyField(max_length=255, db_index=True) usage_key = UsageKeyField(max_length=255, db_index=True) _path = JSONField(db_column='path', help_text='Path in course tree to the block') xblock_cache = models.ForeignKey('bookmarks.XBlockCache', on_delete=models.CASCADE) class Meta: """ Bookmark metadata. """ unique_together = ('user', 'usage_key') def __str__(self): return self.resource_id @classmethod def create(cls, data): """ Create a Bookmark object. Arguments: data (dict): The data to create the object with. Returns: A Bookmark object. Raises: ItemNotFoundError: If no block exists for the usage_key. """ data = dict(data) usage_key = data.pop('usage_key') with modulestore().bulk_operations(usage_key.course_key): block = modulestore().get_item(usage_key) xblock_cache = XBlockCache.create({ 'usage_key': usage_key, 'display_name': block.display_name_with_default, }) data['_path'] = prepare_path_for_serialization( Bookmark.updated_path(usage_key, xblock_cache)) data['course_key'] = usage_key.course_key data['xblock_cache'] = xblock_cache user = data.pop('user') # Sometimes this ends up in data, but newer versions of Django will fail on having unknown keys in defaults data.pop('display_name', None) bookmark, created = cls.objects.get_or_create(usage_key=usage_key, user=user, defaults=data) return bookmark, created @property def resource_id(self): """ Return the resource id: {username,usage_id}. """ return f"{self.user.username},{self.usage_key}" @property def display_name(self): """ Return the display_name from self.xblock_cache. Returns: String. """ return self.xblock_cache.display_name # pylint: disable=no-member @property def path(self): """ Return the path to the bookmark's block after checking self.xblock_cache. Returns: List of dicts. """ if self.modified < self.xblock_cache.modified: # pylint: disable=no-member path = Bookmark.updated_path(self.usage_key, self.xblock_cache) self._path = prepare_path_for_serialization(path) self.save() # Always save so that self.modified is updated. return path return parse_path_data(self._path) @staticmethod def updated_path(usage_key, xblock_cache): """ Return the update-to-date path. xblock_cache.paths is the list of all possible paths to a block constructed by doing a DFS of the tree. However, in case of DAGS, which section jump_to_id() takes the user to depends on the modulestore. If xblock_cache.paths has only one item, we can just use it. Otherwise, we use path_to_location() to get the path jump_to_id() will take the user to. """ if xblock_cache.paths and len(xblock_cache.paths) == 1: return xblock_cache.paths[0] return Bookmark.get_path(usage_key) @staticmethod def get_path(usage_key): """ Returns data for the path to the block in the course graph. Note: In case of multiple paths to the block from the course root, this function returns a path arbitrarily but consistently, depending on the modulestore. In the future, we may want to extend it to check which of the paths, the user has access to and return its data. Arguments: block (XBlock): The block whose path is required. Returns: list of PathItems """ with modulestore().bulk_operations(usage_key.course_key): try: path = search.path_to_location(modulestore(), usage_key, full_path=True) except ItemNotFoundError: log.error('Block with usage_key: %s not found.', usage_key) return [] except NoPathToItem: log.error('No path to block with usage_key: %s.', usage_key) return [] path_data = [] for ancestor_usage_key in path: if ancestor_usage_key != usage_key and ancestor_usage_key.block_type != 'course': try: block = modulestore().get_item(ancestor_usage_key) except ItemNotFoundError: return [] # No valid path can be found. path_data.append( PathItem(usage_key=block.location, display_name=block.display_name_with_default)) return path_data
class CertificateGenerationCourseSetting(TimeStampedModel): """ Enable or disable certificate generation for a particular course. In general, we should only enable self-generated certificates for a course once we successfully generate example certificates for the course. This is enforced in the UI layer, but not in the data layer. .. no_pii: """ course_key = CourseKeyField(max_length=255, db_index=True) self_generation_enabled = models.BooleanField( default=False, help_text= (u"Allow students to generate their own certificates for the course. " u"Enabling this does NOT affect usage of the management command used " u"for batch certificate generation.")) language_specific_templates_enabled = models.BooleanField( default=False, help_text=( u"Render translated certificates rather than using the platform's " u"default language. Available translations are controlled by the " u"certificate template.")) include_hours_of_effort = models.NullBooleanField( default=None, help_text= (u"Display estimated time to complete the course, which is equal to the maximum hours of effort per week " u"times the length of the course in weeks. This attribute will only be displayed in a certificate when the " u"attributes 'Weeks to complete' and 'Max effort' have been provided for the course run and its certificate " u"template includes Hours of Effort.")) class Meta(object): get_latest_by = 'created' app_label = "certificates" @classmethod def get(cls, course_key): """ Retrieve certificate generation settings for a course. Arguments: course_key (CourseKey): The identifier for the course. Returns: CertificateGenerationCourseSetting """ try: latest = cls.objects.filter(course_key=course_key).latest() except cls.DoesNotExist: return None else: return latest @classmethod def is_self_generation_enabled_for_course(cls, course_key): """Check whether self-generated certificates are enabled for a course. Arguments: course_key (CourseKey): The identifier for the course. Returns: boolean """ try: latest = cls.objects.filter(course_key=course_key).latest() except cls.DoesNotExist: return False else: return latest.self_generation_enabled @classmethod def set_self_generatation_enabled_for_course(cls, course_key, is_enabled): """Enable or disable self-generated certificates for a course. Arguments: course_key (CourseKey): The identifier for the course. is_enabled (boolean): Whether to enable or disable self-generated certificates. """ default = {'self_generation_enabled': is_enabled} CertificateGenerationCourseSetting.objects.update_or_create( course_key=course_key, defaults=default)
class RestrictedCourse(models.Model): """Course with access restrictions. Restricted courses can block users at two points: 1) When enrolling in a course. 2) When attempting to access a course the user is already enrolled in. The second case can occur when new restrictions are put into place; for example, when new countries are embargoed. Restricted courses can be configured to display messages to users when they are blocked. These displayed on pages served by the embargo app. """ COURSE_LIST_CACHE_KEY = 'embargo.restricted_courses' MESSAGE_URL_CACHE_KEY = 'embargo.message_url_path.{access_point}.{course_key}' ENROLL_MSG_KEY_CHOICES = tuple([ (msg_key, msg.description) for msg_key, msg in ENROLL_MESSAGES.iteritems() ]) COURSEWARE_MSG_KEY_CHOICES = tuple([ (msg_key, msg.description) for msg_key, msg in COURSEWARE_MESSAGES.iteritems() ]) course_key = CourseKeyField( max_length=255, db_index=True, unique=True, help_text=ugettext_lazy(u"The course key for the restricted course.") ) enroll_msg_key = models.CharField( max_length=255, choices=ENROLL_MSG_KEY_CHOICES, default='default', help_text=ugettext_lazy(u"The message to show when a user is blocked from enrollment.") ) access_msg_key = models.CharField( max_length=255, choices=COURSEWARE_MSG_KEY_CHOICES, default='default', help_text=ugettext_lazy(u"The message to show when a user is blocked from accessing a course.") ) disable_access_check = models.BooleanField( default=False, help_text=ugettext_lazy( u"Allow users who enrolled in an allowed country " u"to access restricted courses from excluded countries." ) ) @classmethod def is_restricted_course(cls, course_id): """ Check if the course is in restricted list Args: course_id (str): course_id to look for Returns: Boolean True if course is in restricted course list. """ return unicode(course_id) in cls._get_restricted_courses_from_cache() @classmethod def is_disabled_access_check(cls, course_id): """ Check if the course is in restricted list has disabled_access_check Args: course_id (str): course_id to look for Returns: Boolean disabled_access_check attribute of restricted course """ # checking is_restricted_course method also here to make sure course exists in the list otherwise in case of # no course found it will throw the key not found error on 'disable_access_check' return ( cls.is_restricted_course(unicode(course_id)) and cls._get_restricted_courses_from_cache().get(unicode(course_id))["disable_access_check"] ) @classmethod def _get_restricted_courses_from_cache(cls): """ Cache all restricted courses and returns the dict of course_keys and disable_access_check that are restricted """ restricted_courses = cache.get(cls.COURSE_LIST_CACHE_KEY) if restricted_courses is None: restricted_courses = { unicode(course.course_key): { 'disable_access_check': course.disable_access_check } for course in RestrictedCourse.objects.all() } cache.set(cls.COURSE_LIST_CACHE_KEY, restricted_courses) return restricted_courses def snapshot(self): """Return a snapshot of all access rules for this course. This is useful for recording an audit trail of rule changes. The returned dictionary is JSON-serializable. Returns: dict Example Usage: >>> restricted_course.snapshot() { 'enroll_msg': 'default', 'access_msg': 'default', 'country_rules': [ {'country': 'IR', 'rule_type': 'blacklist'}, {'country': 'CU', 'rule_type': 'blacklist'} ] } """ country_rules_for_course = ( CountryAccessRule.objects ).select_related('country').filter(restricted_course=self) return { 'enroll_msg': self.enroll_msg_key, 'access_msg': self.access_msg_key, 'country_rules': [ { 'country': unicode(rule.country.country), 'rule_type': rule.rule_type } for rule in country_rules_for_course ] } def message_key_for_access_point(self, access_point): """Determine which message to show the user. The message can be configured per-course and depends on how the user is trying to access the course (trying to enroll or accessing courseware). Arguments: access_point (str): Either "courseware" or "enrollment" Returns: str: The message key. If the access point is not valid, returns None instead. """ if access_point == 'enrollment': return self.enroll_msg_key elif access_point == 'courseware': return self.access_msg_key def __unicode__(self): return unicode(self.course_key) @classmethod def message_url_path(cls, course_key, access_point): """Determine the URL path for the message explaining why the user was blocked. This is configured per-course. See `RestrictedCourse` in the `embargo.models` module for more details. Arguments: course_key (CourseKey): The location of the course. access_point (str): How the user was trying to access the course. Can be either "enrollment" or "courseware". Returns: unicode: The URL path to a page explaining why the user was blocked. Raises: InvalidAccessPoint: Raised if access_point is not a supported value. """ if access_point not in ['enrollment', 'courseware']: raise InvalidAccessPoint(access_point) # First check the cache to see if we already have # a URL for this (course_key, access_point) tuple cache_key = cls.MESSAGE_URL_CACHE_KEY.format( access_point=access_point, course_key=course_key ) url = cache.get(cache_key) # If there's a cache miss, we'll need to retrieve the message # configuration from the database if url is None: url = cls._get_message_url_path_from_db(course_key, access_point) cache.set(cache_key, url) return url @classmethod def _get_message_url_path_from_db(cls, course_key, access_point): """Retrieve the "blocked" message from the database. Arguments: course_key (CourseKey): The location of the course. access_point (str): How the user was trying to access the course. Can be either "enrollment" or "courseware". Returns: unicode: The URL path to a page explaining why the user was blocked. """ # Fallback in case we're not able to find a message path # Presumably if the caller is requesting a URL, the caller # has already determined that the user should be blocked. # We use generic messaging unless we find something more specific, # but *always* return a valid URL path. default_path = reverse( 'embargo:blocked_message', kwargs={ 'access_point': 'courseware', 'message_key': 'default' } ) # First check whether this is a restricted course. # The list of restricted courses is cached, so this does # not require a database query. if not cls.is_restricted_course(course_key): return default_path # Retrieve the message key from the restricted course # for this access point, then determine the URL. try: course = cls.objects.get(course_key=course_key) msg_key = course.message_key_for_access_point(access_point) return reverse( 'embargo:blocked_message', kwargs={ 'access_point': access_point, 'message_key': msg_key } ) except cls.DoesNotExist: # This occurs only if there's a race condition # between cache invalidation and database access. return default_path @classmethod def invalidate_cache_for_course(cls, course_key): """Invalidate the caches for the restricted course. """ cache.delete(cls.COURSE_LIST_CACHE_KEY) log.info("Invalidated cached list of restricted courses.") for access_point in ['enrollment', 'courseware']: msg_cache_key = cls.MESSAGE_URL_CACHE_KEY.format( access_point=access_point, course_key=course_key ) cache.delete(msg_cache_key) log.info("Invalidated cached messaging URLs ")
class VerificationDeadline(TimeStampedModel): """ Represent a verification deadline for a particular course. The verification deadline is the datetime after which users are no longer allowed to submit photos for initial verification in a course. Note that this is NOT the same as the "upgrade" deadline, after which a user is no longer allowed to upgrade to a verified enrollment. If no verification deadline record exists for a course, then that course does not have a deadline. This means that users can submit photos at any time. .. no_pii: """ class Meta(object): app_label = "verify_student" course_key = CourseKeyField( max_length=255, db_index=True, unique=True, help_text=ugettext_lazy(u"The course for which this deadline applies"), ) deadline = models.DateTimeField(help_text=ugettext_lazy( u"The datetime after which users are no longer allowed " "to submit photos for verification.")) # The system prefers to set this automatically based on default settings. But # if the field is set manually we want a way to indicate that so we don't # overwrite the manual setting of the field. deadline_is_explicit = models.BooleanField(default=False) ALL_DEADLINES_CACHE_KEY = "verify_student.all_verification_deadlines" @classmethod def set_deadline(cls, course_key, deadline, is_explicit=False): """ Configure the verification deadline for a course. If `deadline` is `None`, then the course will have no verification deadline. In this case, users will be able to verify for the course at any time. Arguments: course_key (CourseKey): Identifier for the course. deadline (datetime or None): The verification deadline. """ if deadline is None: VerificationDeadline.objects.filter(course_key=course_key).delete() else: record, created = VerificationDeadline.objects.get_or_create( course_key=course_key, defaults={ "deadline": deadline, "deadline_is_explicit": is_explicit }) if not created: record.deadline = deadline record.deadline_is_explicit = is_explicit record.save() @classmethod def deadlines_for_courses(cls, course_keys): """ Retrieve verification deadlines for particular courses. Arguments: course_keys (list): List of `CourseKey`s. Returns: dict: Map of course keys to datetimes (verification deadlines) """ all_deadlines = cache.get(cls.ALL_DEADLINES_CACHE_KEY) if all_deadlines is None: all_deadlines = { deadline.course_key: deadline.deadline for deadline in VerificationDeadline.objects.all() } cache.set(cls.ALL_DEADLINES_CACHE_KEY, all_deadlines) return { course_key: all_deadlines[course_key] for course_key in course_keys if course_key in all_deadlines } @classmethod def deadline_for_course(cls, course_key): """ Retrieve the verification deadline for a particular course. Arguments: course_key (CourseKey): The identifier for the course. Returns: datetime or None """ try: deadline = cls.objects.get(course_key=course_key) return deadline.deadline except cls.DoesNotExist: return None
class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('video_pipeline', '0002_auto_20171114_0704'), ] operations = [ migrations.CreateModel( name='CourseVideoUploadsEnabledByDefault', 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')), ('course_id', CourseKeyField(max_length=255, db_index=True)), ('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='VideoUploadsEnabledByDefault', 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')), ('enabled_for_all_courses', models.BooleanField(default=False)), ('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, }, ), ]
class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='CohortMembership', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('course_id', CourseKeyField(max_length=255)), ], ), migrations.CreateModel( name='CourseCohort', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('assignment_type', models.CharField(default=b'manual', max_length=20, choices=[(b'random', b'Random'), (b'manual', b'Manual')])), ], ), migrations.CreateModel( name='CourseCohortsSettings', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('is_cohorted', models.BooleanField(default=False)), ('course_id', CourseKeyField( help_text= b'Which course are these settings associated with?', unique=True, max_length=255, db_index=True)), ('_cohorted_discussions', models.TextField(null=True, db_column=b'cohorted_discussions', blank=True)), ('always_cohort_inline_discussions', models.BooleanField(default=True)), ], ), migrations.CreateModel( name='CourseUserGroup', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField( help_text= b'What is the name of this group? Must be unique within a course.', max_length=255)), ('course_id', CourseKeyField( help_text=b'Which course is this group associated with?', max_length=255, db_index=True)), ('group_type', models.CharField(max_length=20, choices=[(b'cohort', b'Cohort')])), ('users', models.ManyToManyField(help_text=b'Who is in this group?', related_name='course_groups', to=settings.AUTH_USER_MODEL, db_index=True)), ], ), migrations.CreateModel( name='CourseUserGroupPartitionGroup', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('partition_id', models.IntegerField( help_text= b'contains the id of a cohorted partition in this course') ), ('group_id', models.IntegerField( help_text= b'contains the id of a specific group within the cohorted partition' )), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('course_user_group', models.OneToOneField(to='course_groups.CourseUserGroup')), ], ), migrations.AddField( model_name='coursecohort', name='course_user_group', field=models.OneToOneField(related_name='cohort', to='course_groups.CourseUserGroup'), ), migrations.AddField( model_name='cohortmembership', name='course_user_group', field=models.ForeignKey(to='course_groups.CourseUserGroup'), ), migrations.AddField( model_name='cohortmembership', name='user', field=models.ForeignKey(to=settings.AUTH_USER_MODEL), ), migrations.AlterUniqueTogether( name='courseusergroup', unique_together=set([('name', 'course_id')]), ), migrations.AlterUniqueTogether( name='cohortmembership', unique_together=set([('user', 'course_id')]), ), ]
class VerificationDeadline(TimeStampedModel): """ Represent a verification deadline for a particular course. The verification deadline is the datetime after which users are no longer allowed to submit photos for initial verification in a course. Note that this is NOT the same as the "upgrade" deadline, after which a user is no longer allowed to upgrade to a verified enrollment. If no verification deadline record exists for a course, then that course does not have a deadline. This means that users can submit photos at any time. .. no_pii: """ class Meta: app_label = "verify_student" course_key = CourseKeyField( max_length=255, db_index=True, unique=True, help_text=ugettext_lazy("The course for which this deadline applies"), ) deadline = models.DateTimeField(help_text=ugettext_lazy( "The datetime after which users are no longer allowed " "to submit photos for verification.")) # The system prefers to set this automatically based on default settings. But # if the field is set manually we want a way to indicate that so we don't # overwrite the manual setting of the field. deadline_is_explicit = models.BooleanField(default=False) @classmethod def set_deadline(cls, course_key, deadline, is_explicit=False): """ Configure the verification deadline for a course. If `deadline` is `None`, then the course will have no verification deadline. In this case, users will be able to verify for the course at any time. Arguments: course_key (CourseKey): Identifier for the course. deadline (datetime or None): The verification deadline. """ if deadline is None: VerificationDeadline.objects.filter(course_key=course_key).delete() else: record, created = VerificationDeadline.objects.get_or_create( course_key=course_key, defaults={ "deadline": deadline, "deadline_is_explicit": is_explicit }) if not created: record.deadline = deadline record.deadline_is_explicit = is_explicit record.save() @classmethod def deadlines_for_enrollments(cls, enrollments_qs): """ Retrieve verification deadlines for a user's enrolled courses. Arguments: enrollments_qs: CourseEnrollment queryset. For performance reasons we want the queryset here instead of passing in a big list of course_keys in an "SELECT IN" query. If we have a queryset, Django is smart enough to do a performant subquery at the MySQL layer instead of passing down all the course_keys through Python. Returns: dict: Map of course keys to datetimes (verification deadlines) """ verification_deadlines = VerificationDeadline.objects.filter( course_key__in=enrollments_qs.values('course_id')) return { deadline.course_key: deadline.deadline for deadline in verification_deadlines } @classmethod def deadline_for_course(cls, course_key): """ Retrieve the verification deadline for a particular course. Arguments: course_key (CourseKey): The identifier for the course. Returns: datetime or None """ try: deadline = cls.objects.get(course_key=course_key) return deadline.deadline except cls.DoesNotExist: return None
class ContentLibraryBlockImportTask(models.Model): """ Model of a task to import blocks from an external source (e.g. modulestore). """ library = models.ForeignKey( ContentLibrary, on_delete=models.CASCADE, related_name='import_tasks', ) TASK_CREATED = 'created' TASK_PENDING = 'pending' TASK_RUNNING = 'running' TASK_FAILED = 'failed' TASK_SUCCESSFUL = 'successful' TASK_STATE_CHOICES = ( (TASK_CREATED, _('Task was created, but not queued to run.')), (TASK_PENDING, _('Task was created and queued to run.')), (TASK_RUNNING, _('Task is running.')), (TASK_FAILED, _('Task finished, but some blocks failed to import.')), (TASK_SUCCESSFUL, _('Task finished successfully.')), ) state = models.CharField( choices=TASK_STATE_CHOICES, default=TASK_CREATED, max_length=30, verbose_name=_('state'), help_text=_('The state of the block import task.'), ) progress = models.FloatField( default=0.0, verbose_name=_('progress'), help_text=_('A float from 0.0 to 1.0 representing the task progress.'), ) course_id = CourseKeyField( max_length=255, db_index=True, verbose_name=_('course ID'), help_text=_('ID of the imported course.'), ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-created_at', '-updated_at'] @classmethod @contextlib.contextmanager def execute(cls, import_task_id): """ A context manager to manage a task that is being executed. """ self = cls.objects.get(pk=import_task_id) self.state = self.TASK_RUNNING self.save() try: yield self self.state = self.TASK_SUCCESSFUL except: # pylint: disable=broad-except self.state = self.TASK_FAILED raise finally: self.save() def save_progress(self, progress): self.progress = progress self.save(update_fields=['progress', 'updated_at']) def __str__(self): return f'{self.course_id} to {self.library} #{self.pk}'
class CourseTeam(models.Model): """ This model represents team related info. .. no_pii: """ def __str__(self): return f"{self.name} in {self.course_id}" def __repr__(self): return ( # lint-amnesty, pylint: disable=missing-format-attribute "<CourseTeam" " id={0.id}" " team_id={0.team_id}" " team_size={0.team_size}" " topic_id={0.topic_id}" " course_id={0.course_id}" ">" ).format(self) class Meta: app_label = "teams" team_id = models.SlugField(max_length=255, unique=True) discussion_topic_id = models.SlugField(max_length=255, unique=True) name = models.CharField(max_length=255, db_index=True) course_id = CourseKeyField(max_length=255, db_index=True) topic_id = models.CharField(default='', max_length=255, db_index=True, blank=True) date_created = models.DateTimeField(auto_now_add=True) description = models.CharField(max_length=300) country = CountryField(default='', blank=True) language = LanguageField( default='', blank=True, help_text=ugettext_lazy("Optional language the team uses as ISO 639-1 code."), ) # indexed for ordering last_activity_at = models.DateTimeField(default=utc_now, db_index=True) users = models.ManyToManyField(User, db_index=True, related_name='teams', through='CourseTeamMembership') team_size = models.IntegerField(default=0, db_index=True) # indexed for ordering field_tracker = FieldTracker() # This field would divide the teams into two mutually exclusive groups # If the team is org protected, the members in a team is enrolled into a degree bearing institution # If the team is not org protected, the members in a team is part of the general edX learning community # We need this exclusion for learner privacy protection organization_protected = models.BooleanField(default=False) # Don't emit changed events when these fields change. FIELD_BLACKLIST = ['last_activity_at', 'team_size'] @classmethod def create( cls, name, course_id, description, topic_id='', country='', language='', organization_protected=False ): """Create a complete CourseTeam object. Args: name (str): The name of the team to be created. course_id (str): The ID string of the course associated with this team. description (str): A description of the team. topic_id (str): An optional identifier for the topic the team formed around. country (str, optional): An optional country where the team is based, as ISO 3166-1 code. language (str, optional): An optional language which the team uses, as ISO 639-1 code. organization_protected (bool, optional): specifies whether the team should only contain members who are in a organization context, or not """ unique_id = uuid4().hex team_id = slugify(name)[0:20] + '-' + unique_id discussion_topic_id = unique_id course_team = cls( team_id=team_id, discussion_topic_id=discussion_topic_id, name=name, course_id=course_id, topic_id=topic_id, description=description, country=country, language=language, organization_protected=organization_protected ) return course_team def add_user(self, user): """Adds the given user to the CourseTeam.""" from lms.djangoapps.teams.api import user_protection_status_matches_team if not CourseEnrollment.is_enrolled(user, self.course_id): raise NotEnrolledInCourseForTeam if CourseTeamMembership.user_in_team_for_teamset(user, self.course_id, self.topic_id): raise AlreadyOnTeamInTeamset if not user_protection_status_matches_team(user, self): raise AddToIncompatibleTeamError return CourseTeamMembership.objects.create( user=user, team=self ) def reset_team_size(self): """Reset team_size to reflect the current membership count.""" self.team_size = CourseTeamMembership.objects.filter(team=self).count() self.save()
class ProgramCourseEnrollment(TimeStampedModel): # pylint: disable=model-missing-unicode """ This is a model to represent a learner's enrollment in a course in the context of a program from the registrar service .. no_pii: """ STATUSES = ( ('active', 'active'), ('inactive', 'inactive'), ) class Meta(object): app_label = "program_enrollments" program_enrollment = models.ForeignKey( ProgramEnrollment, on_delete=models.CASCADE, related_name="program_course_enrollments") course_enrollment = models.OneToOneField( CourseEnrollment, null=True, blank=True, ) course_key = CourseKeyField(max_length=255) status = models.CharField(max_length=9, choices=STATUSES) historical_records = HistoricalRecords() def __str__(self): return '[ProgramCourseEnrollment id={}]'.format(self.id) @classmethod def create_program_course_enrollment(cls, program_enrollment, course_key, status): """ Create ProgramCourseEnrollment for the given course and program enrollment """ program_course_enrollment = ProgramCourseEnrollment.objects.create( program_enrollment=program_enrollment, course_key=course_key, status=status, ) if program_enrollment.user: program_course_enrollment.enroll(program_enrollment.user) return program_course_enrollment.status def change_status(self, status): """ Modify ProgramCourseEnrollment status and course_enrollment status if it exists """ if status == self.status: return status self.status = status if self.course_enrollment: if status == ProgramCourseEnrollmentResponseStatuses.ACTIVE: self.course_enrollment.activate() elif status == ProgramCourseEnrollmentResponseStatuses.INACTIVE: self.course_enrollment.deactivate() else: message = ( "Changed {enrollment} status to {status}, not changing course_enrollment" " status because status is not '{active}' or '{inactive}'") logger.warn( message.format( enrollment=self, status=status, active=ProgramCourseEnrollmentResponseStatuses.ACTIVE, inactive=ProgramCourseEnrollmentResponseStatuses. INACTIVE)) elif self.program_enrollment.user: logger.warn( "User {user} {program_enrollment} {course_key} has no course_enrollment" .format( user=self.program_enrollment.user, program_enrollment=self.program_enrollment, course_key=self.course_key, )) self.save() return self.status def enroll(self, user): """ Create a CourseEnrollment to enroll user in course """ try: self.course_enrollment = CourseEnrollment.enroll( user, self.course_key, mode=CourseMode.MASTERS, check_access=True, ) except AlreadyEnrolledError: course_enrollment = CourseEnrollment.objects.get( user=user, course_id=self.course_key, ) if course_enrollment.mode == CourseMode.AUDIT or course_enrollment.mode == CourseMode.HONOR: course_enrollment.mode = CourseMode.MASTERS course_enrollment.save() self.course_enrollment = course_enrollment message = ( "Attempted to create course enrollment for user={user} and course={course}" " but an enrollment already exists. Existing enrollment will be used instead" ) logger.info(message.format(user=user.id, course=self.course_key)) if self.status == ProgramCourseEnrollmentResponseStatuses.INACTIVE: self.course_enrollment.deactivate() self.save()
class CertificateGenerationHistory(TimeStampedModel): """ Model for storing Certificate Generation History. .. no_pii: """ course_id = CourseKeyField(max_length=255) generated_by = models.ForeignKey(User, on_delete=models.CASCADE) instructor_task = models.ForeignKey(InstructorTask, on_delete=models.CASCADE) is_regeneration = models.BooleanField(default=False) def get_task_name(self): """ Return "regenerated" if record corresponds to Certificate Regeneration task, otherwise returns 'generated' """ # Translators: This is a past-tense verb that is used for task action messages. return _("regenerated") if self.is_regeneration else _("generated") def get_certificate_generation_candidates(self): """ Return the candidates for certificate generation task. It could either be students or certificate statuses depending upon the nature of certificate generation task. Returned value could be one of the following, 1. "All learners" Certificate Generation task was initiated for all learners of the given course. 2. Comma separated list of certificate statuses, This usually happens when instructor regenerates certificates. 3. "for exceptions", This is the case when instructor generates certificates for white-listed students. """ task_input = self.instructor_task.task_input if not task_input.strip(): # if task input is empty, it means certificates were generated for all learners # Translators: This string represents task was executed for all learners. return _("All learners") task_input_json = json.loads(task_input) # get statuses_to_regenerate from task_input convert statuses to human readable strings and return statuses = task_input_json.get('statuses_to_regenerate', None) if statuses: readable_statuses = [ CertificateStatuses.readable_statuses.get(status) for status in statuses if CertificateStatuses.readable_statuses.get(status) is not None ] return ", ".join(readable_statuses) # If "student_set" is present in task_input, then this task only # generates certificates for white listed students. Note that # this key used to be "students", so we include that in this conditional # for backwards compatibility. if 'student_set' in task_input_json or 'students' in task_input_json: # Translators: This string represents task was executed for students having exceptions. return _("For exceptions") else: return _("All learners") class Meta(object): app_label = "certificates" def __unicode__(self): return u"certificates %s by %s on %s for %s" % \ ("regenerated" if self.is_regeneration else "generated", self.generated_by, self.created, self.course_id)
class DummyCourse(models.Model): """ .. no_pii: """ id = CourseKeyField(db_index=True, primary_key=True, max_length=255)
class ExampleCertificateSet(TimeStampedModel): """ A set of example certificates. Example certificates are used to verify that certificate generation is working for a particular course. A particular course may have several kinds of certificates (e.g. honor and verified), in which case we generate multiple example certificates for the course. .. no_pii: """ course_key = CourseKeyField(max_length=255, db_index=True) class Meta(object): get_latest_by = 'created' app_label = "certificates" @classmethod @transaction.atomic def create_example_set(cls, course_key): """Create a set of example certificates for a course. Arguments: course_key (CourseKey) Returns: ExampleCertificateSet """ # Import here instead of top of file since this module gets imported before # the course_modes app is loaded, resulting in a Django deprecation warning. from course_modes.models import CourseMode cert_set = cls.objects.create(course_key=course_key) ExampleCertificate.objects.bulk_create([ ExampleCertificate(example_cert_set=cert_set, description=mode.slug, template=cls._template_for_mode( mode.slug, course_key)) for mode in CourseMode.modes_for_course(course_key) ]) return cert_set @classmethod def latest_status(cls, course_key): """Summarize the latest status of example certificates for a course. Arguments: course_key (CourseKey) Returns: list: List of status dictionaries. If no example certificates have been started yet, returns None. """ try: latest = cls.objects.filter(course_key=course_key).latest() except cls.DoesNotExist: return None queryset = ExampleCertificate.objects.filter( example_cert_set=latest).order_by('-created') return [cert.status_dict for cert in queryset] def __iter__(self): """Iterate through example certificates in the set. Yields: ExampleCertificate """ queryset = (ExampleCertificate.objects ).select_related('example_cert_set').filter( example_cert_set=self) for cert in queryset: yield cert @staticmethod def _template_for_mode(mode_slug, course_key): """Calculate the template PDF based on the course mode. """ return ( u"certificate-template-{key.org}-{key.course}-verified.pdf".format( key=course_key) if mode_slug == 'verified' else u"certificate-template-{key.org}-{key.course}.pdf".format( key=course_key))
class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='CreditCourse', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('course_key', CourseKeyField(unique=True, max_length=255, db_index=True)), ('enabled', models.BooleanField(default=False)), ], ), migrations.CreateModel( name='CreditEligibility', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('username', models.CharField(max_length=255, db_index=True)), ('deadline', models.DateTimeField( default=openedx.core.djangoapps.credit.models. default_deadline_for_credit_eligibility, help_text='Deadline for purchasing and requesting credit.' )), ('course', models.ForeignKey(related_name='eligibilities', to='credit.CreditCourse', on_delete=models.CASCADE)), ], options={ 'verbose_name_plural': 'Credit eligibilities', }, ), migrations.CreateModel( name='CreditProvider', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('provider_id', models.CharField( help_text= 'Unique identifier for this credit provider. Only alphanumeric characters and hyphens (-) are allowed. The identifier is case-sensitive.', unique=True, max_length=255, validators=[ django.core.validators.RegexValidator( regex=u'[a-z,A-Z,0-9,\\-]+', message= u'Only alphanumeric characters and hyphens (-) are allowed', code=u'invalid_provider_id') ])), ('active', models.BooleanField( default=True, help_text= 'Whether the credit provider is currently enabled.')), ('display_name', models.CharField( help_text='Name of the credit provider displayed to users', max_length=255)), ('enable_integration', models.BooleanField( default=False, help_text= 'When true, automatically notify the credit provider when a user requests credit. In order for this to work, a shared secret key MUST be configured for the credit provider in secure auth settings.' )), ('provider_url', models.URLField( default=u'', help_text= 'URL of the credit provider. If automatic integration is enabled, this will the the end-point that we POST to to notify the provider of a credit request. Otherwise, the user will be shown a link to this URL, so the user can request credit from the provider directly.' )), ('provider_status_url', models.URLField( default=u'', help_text= 'URL from the credit provider where the user can check the status of his or her request for credit. This is displayed to students *after* they have requested credit.' )), ('provider_description', models.TextField( default=u'', help_text= 'Description for the credit provider displayed to users.') ), ('fulfillment_instructions', models.TextField( help_text= 'Plain text or html content for displaying further steps on receipt page *after* paying for the credit to get credit for a credit course against a credit provider.', null=True, blank=True)), ('eligibility_email_message', models.TextField( default=u'', help_text= 'Plain text or html content for displaying custom message inside credit eligibility email content which is sent when user has met all credit eligibility requirements.' )), ('receipt_email_message', models.TextField( default=u'', help_text= 'Plain text or html content for displaying custom message inside credit receipt email content which is sent *after* paying to get credit for a credit course.' )), ('thumbnail_url', models.URLField( default=u'', help_text='Thumbnail image url of the credit provider.', max_length=255)), ], options={ 'abstract': False, }, ), migrations.CreateModel( name='CreditRequest', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('uuid', models.CharField(unique=True, max_length=32, db_index=True)), ('username', models.CharField(max_length=255, db_index=True)), ('parameters', jsonfield.fields.JSONField()), ('status', models.CharField(default=u'pending', max_length=255, choices=[(u'pending', u'Pending'), (u'approved', u'Approved'), (u'rejected', u'Rejected')])), ('course', models.ForeignKey(related_name='credit_requests', to='credit.CreditCourse', on_delete=models.CASCADE)), ('provider', models.ForeignKey(related_name='credit_requests', to='credit.CreditProvider', on_delete=models.CASCADE)), ], options={ 'get_latest_by': 'created', }, ), migrations.CreateModel( name='CreditRequirement', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('namespace', models.CharField(max_length=255)), ('name', models.CharField(max_length=255)), ('display_name', models.CharField(default=u'', max_length=255)), ('order', models.PositiveIntegerField(default=0)), ('criteria', jsonfield.fields.JSONField()), ('active', models.BooleanField(default=True)), ('course', models.ForeignKey(related_name='credit_requirements', to='credit.CreditCourse', on_delete=models.CASCADE)), ], options={ 'ordering': ['order'], }, ), migrations.CreateModel( name='CreditRequirementStatus', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('username', models.CharField(max_length=255, db_index=True)), ('status', models.CharField(max_length=32, choices=[(u'satisfied', u'satisfied'), (u'failed', u'failed'), (u'declined', u'declined')])), ('reason', jsonfield.fields.JSONField(default={})), ('requirement', models.ForeignKey(related_name='statuses', to='credit.CreditRequirement', on_delete=models.CASCADE)), ], ), migrations.CreateModel( name='HistoricalCreditRequest', fields=[ ('id', models.IntegerField(verbose_name='ID', db_index=True, auto_created=True, blank=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('uuid', models.CharField(max_length=32, db_index=True)), ('username', models.CharField(max_length=255, db_index=True)), ('parameters', jsonfield.fields.JSONField()), ('status', models.CharField(default=u'pending', max_length=255, choices=[(u'pending', u'Pending'), (u'approved', u'Approved'), (u'rejected', u'Rejected')])), ('history_id', models.AutoField(serialize=False, primary_key=True)), ('history_date', models.DateTimeField()), ('history_type', models.CharField(max_length=1, choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')])), ('course', models.ForeignKey( related_name='+', on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to='credit.CreditCourse', null=True)), ('history_user', models.ForeignKey( related_name='+', on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, null=True)), ('provider', models.ForeignKey( related_name='+', on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to='credit.CreditProvider', null=True)), ], options={ 'ordering': ('-history_date', '-history_id'), 'get_latest_by': 'history_date', 'verbose_name': 'historical credit request', }, ), migrations.CreateModel( name='HistoricalCreditRequirementStatus', fields=[ ('id', models.IntegerField(verbose_name='ID', db_index=True, auto_created=True, blank=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('username', models.CharField(max_length=255, db_index=True)), ('status', models.CharField(max_length=32, choices=[(u'satisfied', u'satisfied'), (u'failed', u'failed'), (u'declined', u'declined')])), ('reason', jsonfield.fields.JSONField(default={})), ('history_id', models.AutoField(serialize=False, primary_key=True)), ('history_date', models.DateTimeField()), ('history_type', models.CharField(max_length=1, choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')])), ('history_user', models.ForeignKey( related_name='+', on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, null=True)), ('requirement', models.ForeignKey( related_name='+', on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to='credit.CreditRequirement', null=True)), ], options={ 'ordering': ('-history_date', '-history_id'), 'get_latest_by': 'history_date', 'verbose_name': 'historical credit requirement status', }, ), migrations.AlterUniqueTogether( name='creditrequirementstatus', unique_together=set([('username', 'requirement')]), ), migrations.AlterUniqueTogether( name='creditrequirement', unique_together=set([('namespace', 'name', 'course')]), ), migrations.AlterUniqueTogether( name='creditrequest', unique_together=set([('username', 'course', 'provider')]), ), migrations.AlterUniqueTogether( name='crediteligibility', unique_together=set([('username', 'course')]), ), ]
class CourseAccessRuleHistory(models.Model): """History of course access rule changes. """ # pylint: disable=model-missing-unicode timestamp = models.DateTimeField(db_index=True, auto_now_add=True) course_key = CourseKeyField(max_length=255, db_index=True) snapshot = models.TextField(null=True, blank=True) DELETED_PLACEHOLDER = "DELETED" @classmethod def save_snapshot(cls, restricted_course, deleted=False): """Save a snapshot of access rules for a course. Arguments: restricted_course (RestrictedCourse) Keyword Arguments: deleted (boolean): If True, the restricted course is about to be deleted. Create a placeholder snapshot recording that the course and all its rules was deleted. Returns: None """ course_key = restricted_course.course_key # At the point this is called, the access rules may not have # been deleted yet. When the rules *are* deleted, the # restricted course entry may no longer exist, so we # won't be able to take a snapshot of the rules. # To handle this, we save a placeholder "DELETED" entry # so that it's clear in the audit that the restricted # course (along with all its rules) was deleted. snapshot = ( CourseAccessRuleHistory.DELETED_PLACEHOLDER if deleted else json.dumps(restricted_course.snapshot()) ) cls.objects.create( course_key=course_key, snapshot=snapshot ) @staticmethod def snapshot_post_save_receiver(sender, instance, **kwargs): # pylint: disable=unused-argument """Create a snapshot of course access rules when the rules are updated. """ if isinstance(instance, RestrictedCourse): CourseAccessRuleHistory.save_snapshot(instance) elif isinstance(instance, CountryAccessRule): CourseAccessRuleHistory.save_snapshot(instance.restricted_course) @staticmethod def snapshot_post_delete_receiver(sender, instance, **kwargs): # pylint: disable=unused-argument """Create a snapshot of course access rules when rules are deleted. """ if isinstance(instance, RestrictedCourse): CourseAccessRuleHistory.save_snapshot(instance, deleted=True) elif isinstance(instance, CountryAccessRule): try: restricted_course = instance.restricted_course except RestrictedCourse.DoesNotExist: # When Django admin deletes a restricted course, it will # also delete the rules associated with that course. # At this point, we can't access the restricted course # from the rule beause it may already have been deleted. # If this happens, we don't need to record anything, # since we already record a placeholder "DELETED" # entry when the restricted course record is deleted. pass else: CourseAccessRuleHistory.save_snapshot(restricted_course) class Meta(object): get_latest_by = 'timestamp'
class InstructorTask(models.Model): """ Stores information about background tasks that have been submitted to perform work by an instructor (or course staff). Examples include grading and rescoring. `task_type` identifies the kind of task being performed, e.g. rescoring. `course_id` uses the course run's unique id to identify the course. `task_key` stores relevant input arguments encoded into key value for testing to see if the task is already running (together with task_type and course_id). `task_input` stores input arguments as JSON-serialized dict, for reporting purposes. Examples include url of problem being rescored, id of student if only one student being rescored. `task_id` stores the id used by celery for the background task. `task_state` stores the last known state of the celery task `task_output` stores the output of the celery task. Format is a JSON-serialized dict. Content varies by task_type and task_state. `requester` stores id of user who submitted the task `created` stores date that entry was first created `updated` stores date that entry was last modified .. no_pii: """ class Meta(object): app_label = "instructor_task" task_type = models.CharField(max_length=50, db_index=True) course_id = CourseKeyField(max_length=255, db_index=True) task_key = models.CharField(max_length=255, db_index=True) task_input = models.TextField() task_id = models.CharField( max_length=255, db_index=True) # max_length from celery_taskmeta task_state = models.CharField( max_length=50, null=True, db_index=True) # max_length from celery_taskmeta task_output = models.CharField(max_length=1024, null=True) requester = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) created = models.DateTimeField(auto_now_add=True, null=True) updated = models.DateTimeField(auto_now=True) subtasks = models.TextField(blank=True) # JSON dictionary def __repr__(self): return 'InstructorTask<%r>' % ({ 'task_type': self.task_type, 'course_id': self.course_id, 'task_input': self.task_input, 'task_id': self.task_id, 'task_state': self.task_state, 'task_output': self.task_output, }, ) def __str__(self): return six.text_type(repr(self)) @classmethod def create(cls, course_id, task_type, task_key, task_input, requester): """ Create an instance of InstructorTask. """ # create the task_id here, and pass it into celery: task_id = str(uuid4()) json_task_input = json.dumps(task_input) # check length of task_input, and return an exception if it's too long if len(json_task_input) > TASK_INPUT_LENGTH: logger.error( u'Task input longer than: `%s` for `%s` of course: `%s`', TASK_INPUT_LENGTH, task_type, course_id) error_msg = _('An error has occurred. Task was not created.') raise AttributeError(error_msg) # create the task, then save it: instructor_task = cls(course_id=course_id, task_type=task_type, task_id=task_id, task_key=task_key, task_input=json_task_input, task_state=QUEUING, requester=requester) instructor_task.save_now() return instructor_task @transaction.atomic def save_now(self): """ Writes InstructorTask immediately, ensuring the transaction is committed. """ self.save() @staticmethod def create_output_for_success(returned_result): """ Converts successful result to output format. Raises a ValueError exception if the output is too long. """ # In future, there should be a check here that the resulting JSON # will fit in the column. In the meantime, just return an exception. json_output = json.dumps(returned_result) if len(json_output) > 1023: raise ValueError( u"Length of task output is too long: {0}".format(json_output)) return json_output @staticmethod def create_output_for_failure(exception, traceback_string): """ Converts failed result information to output format. Traceback information is truncated or not included if it would result in an output string that would not fit in the database. If the output is still too long, then the exception message is also truncated. Truncation is indicated by adding "..." to the end of the value. """ tag = '...' task_progress = { 'exception': type(exception).__name__, 'message': text_type(exception) } if traceback_string is not None: # truncate any traceback that goes into the InstructorTask model: task_progress['traceback'] = traceback_string json_output = json.dumps(task_progress) # if the resulting output is too long, then first shorten the # traceback, and then the message, until it fits. too_long = len(json_output) - 1023 if too_long > 0: if traceback_string is not None: if too_long >= len(traceback_string) - len(tag): # remove the traceback entry entirely (so no key or value) del task_progress['traceback'] too_long -= (len(traceback_string) + len('traceback')) else: # truncate the traceback: task_progress['traceback'] = traceback_string[:-( too_long + len(tag))] + tag too_long = 0 if too_long > 0: # we need to shorten the message: task_progress['message'] = task_progress['message'][:-( too_long + len(tag))] + tag json_output = json.dumps(task_progress) return json_output @staticmethod def create_output_for_revoked(): """Creates standard message to store in output format for revoked tasks.""" return json.dumps({'message': 'Task revoked before running'})
class CustomCourseForEdX(models.Model): """ A Custom Course. .. no_pii: """ course_id = CourseKeyField(max_length=255, db_index=True) display_name = models.CharField(max_length=255) coach = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) # if not empty, this field contains a json serialized list of # the master course modules structure_json = models.TextField(verbose_name='Structure JSON', blank=True, null=True) class Meta(object): app_label = 'ccx' @lazy def course(self): """Return the CourseDescriptor of the course related to this CCX""" store = modulestore() with store.bulk_operations(self.course_id): course = store.get_course(self.course_id) if not course or isinstance(course, ErrorDescriptor): log.error("CCX {0} from {2} course {1}".format( # pylint: disable=logging-format-interpolation self.display_name, self.course_id, "broken" if course else "non-existent")) return course @lazy def start(self): """Get the value of the override of the 'start' datetime for this CCX """ # avoid circular import problems from .overrides import get_override_for_ccx return get_override_for_ccx(self, self.course, 'start') @lazy def due(self): """Get the value of the override of the 'due' datetime for this CCX """ # avoid circular import problems from .overrides import get_override_for_ccx return get_override_for_ccx(self, self.course, 'due') @lazy def max_student_enrollments_allowed(self): """ Get the value of the override of the 'max_student_enrollments_allowed' datetime for this CCX """ # avoid circular import problems from .overrides import get_override_for_ccx return get_override_for_ccx(self, self.course, 'max_student_enrollments_allowed') def has_started(self): """Return True if the CCX start date is in the past""" return datetime.now(utc) > self.start def has_ended(self): """Return True if the CCX due date is set and is in the past""" if self.due is None: return False return datetime.now(utc) > self.due @property def structure(self): """ Deserializes a course structure JSON object """ if self.structure_json: return json.loads(self.structure_json) return None @property def locator(self): """ Helper property that gets a corresponding CCXLocator for this CCX. Returns: The CCXLocator corresponding to this CCX. """ return CCXLocator.from_course_locator(self.course_id, unicode(self.id))
class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='Bookmark', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('course_key', CourseKeyField(max_length=255, db_index=True)), ('usage_key', UsageKeyField(max_length=255, db_index=True)), ('_path', jsonfield.fields.JSONField( help_text='Path in course tree to the block', db_column='path')), ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], ), migrations.CreateModel( name='XBlockCache', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('course_key', CourseKeyField(max_length=255, db_index=True)), ('usage_key', UsageKeyField(unique=True, max_length=255, db_index=True)), ('display_name', models.CharField(default='', max_length=255)), ('_paths', jsonfield.fields.JSONField( default=[], help_text= 'All paths in course tree to the corresponding block.', db_column='paths')), ], options={ 'abstract': False, }, ), migrations.AddField( model_name='bookmark', name='xblock_cache', field=models.ForeignKey(to='bookmarks.XBlockCache', on_delete=models.CASCADE), ), migrations.AlterUniqueTogether( name='bookmark', unique_together=set([('user', 'usage_key')]), ), ]
class XBlockCache(TimeStampedModel): """ XBlockCache model to store info about xblocks. .. no_pii: """ course_key = CourseKeyField(max_length=255, db_index=True) usage_key = UsageKeyField(max_length=255, db_index=True, unique=True) display_name = models.CharField(max_length=255, default='') _paths = JSONField( db_column='paths', default=[], help_text='All paths in course tree to the corresponding block.') def __str__(self): return str(self.usage_key) @property def paths(self): """ Return paths. Returns: list of list of PathItems. """ return [parse_path_data(path) for path in self._paths] if self._paths else self._paths @paths.setter def paths(self, value): """ Set paths. Arguments: value (list of list of PathItems): The list of paths to cache. """ self._paths = [prepare_path_for_serialization(path) for path in value] if value else value @classmethod def create(cls, data): """ Create an XBlockCache object. Arguments: data (dict): The data to create the object with. Returns: An XBlockCache object. """ data = dict(data) usage_key = data.pop('usage_key') usage_key = usage_key.replace( course_key=modulestore().fill_in_run(usage_key.course_key)) data['course_key'] = usage_key.course_key xblock_cache, created = cls.objects.get_or_create(usage_key=usage_key, defaults=data) if not created: new_display_name = data.get('display_name', xblock_cache.display_name) if xblock_cache.display_name != new_display_name: xblock_cache.display_name = new_display_name xblock_cache.save() return xblock_cache
class CertificateTemplate(TimeStampedModel): """A set of custom web certificate templates. Web certificate templates are Django web templates to replace PDF certificate. A particular course may have several kinds of certificate templates (e.g. honor and verified). .. no_pii: """ name = models.CharField( max_length=255, help_text=_(u'Name of template.'), ) description = models.CharField( max_length=255, null=True, blank=True, help_text=_(u'Description and/or admin notes.'), ) template = models.TextField(help_text=_(u'Django template HTML.'), ) organization_id = models.IntegerField( null=True, blank=True, db_index=True, help_text=_(u'Organization of template.'), ) course_key = CourseKeyField( max_length=255, null=True, blank=True, db_index=True, ) mode = models.CharField( max_length=125, choices=GeneratedCertificate.MODES, default=GeneratedCertificate.MODES.honor, null=True, blank=True, help_text=_(u'The course mode for this template.'), ) is_active = models.BooleanField( help_text=_(u'On/Off switch.'), default=False, ) language = models.CharField( max_length=2, blank=True, null=True, help_text= u'Only certificates for courses in the selected language will be rendered using this template. ' u'Course language is determined by the first two letters of the language code.' ) def __unicode__(self): return u'%s' % (self.name, ) class Meta(object): get_latest_by = 'created' unique_together = (('organization_id', 'course_key', 'mode', 'language'), ) app_label = "certificates"
class CourseTeam(models.Model): """ This model represents team related info. .. no_pii: """ def __str__(self): return "{} in {}".format(self.name, self.course_id) def __repr__(self): return ("<CourseTeam" " id={0.id}" " team_id={0.team_id}" " team_size={0.team_size}" " topic_id={0.topic_id}" " course_id={0.course_id}" ">").format(self) class Meta(object): app_label = "teams" team_id = models.SlugField(max_length=255, unique=True) discussion_topic_id = models.SlugField(max_length=255, unique=True) name = models.CharField(max_length=255, db_index=True) course_id = CourseKeyField(max_length=255, db_index=True) topic_id = models.CharField(max_length=255, db_index=True, blank=True) date_created = models.DateTimeField(auto_now_add=True) description = models.CharField(max_length=300) country = CountryField(blank=True) language = LanguageField( blank=True, help_text=ugettext_lazy( "Optional language the team uses as ISO 639-1 code."), ) last_activity_at = models.DateTimeField( db_index=True) # indexed for ordering users = models.ManyToManyField(User, db_index=True, related_name='teams', through='CourseTeamMembership') team_size = models.IntegerField(default=0, db_index=True) # indexed for ordering field_tracker = FieldTracker() # Don't emit changed events when these fields change. FIELD_BLACKLIST = ['last_activity_at', 'team_size'] @classmethod def create(cls, name, course_id, description, topic_id=None, country=None, language=None): """Create a complete CourseTeam object. Args: name (str): The name of the team to be created. course_id (str): The ID string of the course associated with this team. description (str): A description of the team. topic_id (str): An optional identifier for the topic the team formed around. country (str, optional): An optional country where the team is based, as ISO 3166-1 code. language (str, optional): An optional language which the team uses, as ISO 639-1 code. """ unique_id = uuid4().hex team_id = slugify(name)[0:20] + '-' + unique_id discussion_topic_id = unique_id course_team = cls( team_id=team_id, discussion_topic_id=discussion_topic_id, name=name, course_id=course_id, topic_id=topic_id if topic_id else '', description=description, country=country if country else '', language=language if language else '', last_activity_at=datetime.utcnow().replace(tzinfo=pytz.utc)) return course_team def add_user(self, user): """Adds the given user to the CourseTeam.""" if not CourseEnrollment.is_enrolled(user, self.course_id): raise NotEnrolledInCourseForTeam if CourseTeamMembership.user_in_team_for_course(user, self.course_id): raise AlreadyOnTeamInCourse return CourseTeamMembership.objects.create(user=user, team=self) def reset_team_size(self): """Reset team_size to reflect the current membership count.""" self.team_size = CourseTeamMembership.objects.filter(team=self).count() self.save()
class CertificateWhitelist(models.Model): """ Tracks students who are whitelisted, all users in this table will always qualify for a certificate regardless of their grade unless they are on the embargoed country restriction list (allow_certificate set to False in userprofile). .. no_pii: """ class Meta(object): app_label = "certificates" objects = NoneToEmptyManager() user = models.ForeignKey(User, on_delete=models.CASCADE) course_id = CourseKeyField(max_length=255, blank=True, default=None) whitelist = models.BooleanField(default=0) created = AutoCreatedField(_('created')) notes = models.TextField(default=None, null=True) @classmethod def get_certificate_white_list(cls, course_id, student=None): """ Return certificate white list for the given course as dict object, returned dictionary will have the following key-value pairs [{ id: 'id (pk) of CertificateWhitelist item' user_id: 'User Id of the student' user_name: 'name of the student' user_email: 'email of the student' course_id: 'Course key of the course to whom certificate exception belongs' created: 'Creation date of the certificate exception' notes: 'Additional notes for the certificate exception' }, {...}, ...] """ white_list = cls.objects.filter(course_id=course_id, whitelist=True) if student: white_list = white_list.filter(user=student) result = [] generated_certificates = GeneratedCertificate.eligible_certificates.filter( course_id=course_id, user__in=[exception.user for exception in white_list], status=CertificateStatuses.downloadable) generated_certificates = { certificate['user']: certificate['created_date'] for certificate in generated_certificates.values( 'user', 'created_date') } for item in white_list: certificate_generated = generated_certificates.get( item.user.id, '') result.append({ 'id': item.id, 'user_id': item.user.id, 'user_name': unicode(item.user.username), 'user_email': unicode(item.user.email), 'course_id': unicode(item.course_id), 'created': item.created.strftime(u"%B %d, %Y"), 'certificate_generated': certificate_generated and certificate_generated.strftime(u"%B %d, %Y"), 'notes': unicode(item.notes or ''), }) return result
class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='BadgeAssertion', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('course_id', CourseKeyField(default=None, max_length=255, blank=True)), ('mode', models.CharField(max_length=100)), ('data', jsonfield.fields.JSONField()), ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], ), migrations.CreateModel( name='BadgeImageConfiguration', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('mode', models.CharField( help_text= 'The course mode for this badge image. For example, "verified" or "honor".', unique=True, max_length=125)), ('icon', models.ImageField( help_text= 'Badge images must be square PNG files. The file size should be under 250KB.', upload_to='badges', validators=[validate_badge_image])), ('default', models.BooleanField( default=False, help_text= 'Set this value to True if you want this image to be the default image for any course modes that do not have a specified badge image. You can have only one default image.' )), ], ), migrations.CreateModel( name='CertificateGenerationConfiguration', 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')), ], options={ 'ordering': ('-change_date', ), 'abstract': False, }, ), migrations.CreateModel( name='CertificateGenerationCourseSetting', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('course_key', CourseKeyField(max_length=255, db_index=True)), ('enabled', models.BooleanField(default=False)), ], options={ 'get_latest_by': 'created', }, ), migrations.CreateModel( name='CertificateHtmlViewConfiguration', 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')), ('configuration', models.TextField( help_text=u'Certificate HTML View Parameters (JSON)')), ('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='CertificateTemplate', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('name', models.CharField(help_text='Name of template.', max_length=255)), ('description', models.CharField(help_text='Description and/or admin notes.', max_length=255, null=True, blank=True)), ('template', models.TextField(help_text='Django template HTML.')), ('organization_id', models.IntegerField(help_text='Organization of template.', null=True, db_index=True, blank=True)), ('course_key', CourseKeyField(db_index=True, max_length=255, null=True, blank=True)), ('mode', models.CharField( default=b'honor', choices=[(b'verified', b'verified'), (b'honor', b'honor'), (b'audit', b'audit'), (b'professional', b'professional'), (b'no-id-professional', b'no-id-professional')], max_length=125, blank=True, help_text='The course mode for this template.', null=True)), ('is_active', models.BooleanField(default=False, help_text='On/Off switch.')), ], options={ 'get_latest_by': 'created', }, ), migrations.CreateModel( name='CertificateTemplateAsset', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('description', models.CharField(help_text='Description of the asset.', max_length=255, null=True, blank=True)), ('asset', models.FileField( help_text='Asset file. It could be an image or css file.', max_length=255, upload_to=cert_models.template_assets_path)), ], options={ 'get_latest_by': 'created', }, ), migrations.CreateModel( name='CertificateWhitelist', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('course_id', CourseKeyField(default=None, max_length=255, blank=True)), ('whitelist', models.BooleanField(default=0)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('notes', models.TextField(default=None, null=True)), ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], ), migrations.CreateModel( name='ExampleCertificate', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('description', models.CharField( help_text= "A human-readable description of the example certificate. For example, 'verified' or 'honor' to differentiate between two types of certificates.", max_length=255)), ('uuid', models.CharField( default=cert_models._make_uuid, help_text= 'A unique identifier for the example certificate. This is used when we receive a response from the queue to determine which example certificate was processed.', unique=True, max_length=255, db_index=True)), ('access_key', models.CharField( default=cert_models._make_uuid, help_text= 'An access key for the example certificate. This is used when we receive a response from the queue to validate that the sender is the same entity we asked to generate the certificate.', max_length=255, db_index=True)), ('full_name', models.CharField( default='John Do\xeb', help_text= 'The full name that will appear on the certificate.', max_length=255)), ('template', models.CharField( help_text= 'The template file to use when generating the certificate.', max_length=255)), ('status', models.CharField( default=u'started', help_text='The status of the example certificate.', max_length=255, choices=[(u'started', u'Started'), (u'success', u'Success'), (u'error', u'Error')])), ('error_reason', models.TextField( default=None, help_text= 'The reason an error occurred during certificate generation.', null=True)), ('download_url', models.CharField( default=None, max_length=255, null=True, help_text='The download URL for the generated certificate.' )), ], ), migrations.CreateModel( name='ExampleCertificateSet', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', model_utils.fields.AutoCreatedField( default=django.utils.timezone.now, verbose_name='created', editable=False)), ('modified', model_utils.fields.AutoLastModifiedField( default=django.utils.timezone.now, verbose_name='modified', editable=False)), ('course_key', CourseKeyField(max_length=255, db_index=True)), ], options={ 'get_latest_by': 'created', }, ), migrations.CreateModel( name='GeneratedCertificate', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('course_id', CourseKeyField(default=None, max_length=255, blank=True)), ('verify_uuid', models.CharField(default=u'', max_length=32, blank=True)), ('download_uuid', models.CharField(default=u'', max_length=32, blank=True)), ('download_url', models.CharField(default=u'', max_length=128, blank=True)), ('grade', models.CharField(default=u'', max_length=5, blank=True)), ('key', models.CharField(default=u'', max_length=32, blank=True)), ('distinction', models.BooleanField(default=False)), ('status', models.CharField(default=u'unavailable', max_length=32)), ('mode', models.CharField(default=u'honor', max_length=32, choices=[(u'verified', u'verified'), (u'honor', u'honor'), (u'audit', u'audit'), (u'professional', u'professional'), (u'no-id-professional', u'no-id-professional')])), ('name', models.CharField(max_length=255, blank=True)), ('created_date', models.DateTimeField(auto_now_add=True)), ('modified_date', models.DateTimeField(auto_now=True)), ('error_reason', models.CharField(default=u'', max_length=512, blank=True)), ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], ), migrations.AddField( model_name='examplecertificate', name='example_cert_set', field=models.ForeignKey(to='certificates.ExampleCertificateSet', on_delete=models.CASCADE), ), migrations.AlterUniqueTogether( name='certificatetemplate', unique_together=set([('organization_id', 'course_key', 'mode')]), ), migrations.AlterUniqueTogether( name='generatedcertificate', unique_together=set([('user', 'course_id')]), ), migrations.AlterUniqueTogether( name='badgeassertion', unique_together=set([('course_id', 'user', 'mode')]), ), ]
class GeneratedCertificate(models.Model): """ Base model for generated certificates .. pii: PII can exist in the generated certificate linked to in this model. Certificate data is currently retained. .. pii_types: name, username .. pii_retirement: retained """ # Import here instead of top of file since this module gets imported before # the course_modes app is loaded, resulting in a Django deprecation warning. from course_modes.models import CourseMode # Only returns eligible certificates. This should be used in # preference to the default `objects` manager in most cases. eligible_certificates = EligibleCertificateManager() # Only returns eligible certificates for courses that have an # associated CourseOverview eligible_available_certificates = EligibleAvailableCertificateManager() # Normal object manager, which should only be used when ineligible # certificates (i.e. new audit certs) should be included in the # results. Django requires us to explicitly declare this. objects = models.Manager() MODES = Choices('verified', 'honor', 'audit', 'professional', 'no-id-professional', 'masters') VERIFIED_CERTS_MODES = [ CourseMode.VERIFIED, CourseMode.CREDIT_MODE, CourseMode.MASTERS ] user = models.ForeignKey(User, on_delete=models.CASCADE) course_id = CourseKeyField(max_length=255, blank=True, default=None) verify_uuid = models.CharField(max_length=32, blank=True, default='', db_index=True) download_uuid = models.CharField(max_length=32, blank=True, default='') download_url = models.CharField(max_length=128, blank=True, default='') grade = models.CharField(max_length=5, blank=True, default='') key = models.CharField(max_length=32, blank=True, default='') distinction = models.BooleanField(default=False) status = models.CharField(max_length=32, default='unavailable') mode = models.CharField(max_length=32, choices=MODES, default=MODES.honor) name = models.CharField(blank=True, max_length=255) created_date = models.DateTimeField(auto_now_add=True) modified_date = models.DateTimeField(auto_now=True) error_reason = models.CharField(max_length=512, blank=True, default='') class Meta(object): unique_together = (('user', 'course_id'), ) app_label = "certificates" @classmethod def certificate_for_student(cls, student, course_id): """ This returns the certificate for a student for a particular course or None if no such certificate exits. """ try: return cls.objects.get(user=student, course_id=course_id) except cls.DoesNotExist: pass return None @classmethod def course_ids_with_certs_for_user(cls, user): """ Return a set of CourseKeys for which the user has certificates. Sometimes we just want to check if a user has already been issued a certificate for a given course (e.g. to test refund elibigility). Instead of checking if `certificate_for_student` returns `None` on each course_id individually, we instead just return a set of all CourseKeys for which this student has certificates all at once. """ return { cert.course_id for cert in cls.objects.filter(user=user).only('course_id') # pylint: disable=no-member } @classmethod def get_unique_statuses(cls, course_key=None, flat=False): """ 1 - Return unique statuses as a list of dictionaries containing the following key value pairs [ {'status': 'status value from db', 'count': 'occurrence count of the status'}, {...}, ..., ] 2 - if flat is 'True' then return unique statuses as a list 3 - if course_key is given then return unique statuses associated with the given course :param course_key: Course Key identifier :param flat: boolean showing whether to return statuses as a list of values or a list of dictionaries. """ query = cls.objects if course_key: query = query.filter(course_id=course_key) if flat: return query.values_list('status', flat=True).distinct() else: return query.values('status').annotate(count=Count('status')) def __repr__(self): return "<GeneratedCertificate: {course_id}, user={user}>".format( course_id=self.course_id, user=self.user) def invalidate(self): """ Invalidate Generated Certificate by marking it 'unavailable'. Following is the list of fields with their defaults 1 - verify_uuid = '', 2 - download_uuid = '', 3 - download_url = '', 4 - grade = '' 5 - status = 'unavailable' """ self.verify_uuid = '' self.download_uuid = '' self.download_url = '' self.grade = '' self.status = CertificateStatuses.unavailable self.save() def mark_notpassing(self, grade): """ Invalidates a Generated Certificate by marking it as not passing """ self.verify_uuid = '' self.download_uuid = '' self.download_url = '' self.grade = grade self.status = CertificateStatuses.notpassing self.save() def is_valid(self): """ Return True if certificate is valid else return False. """ return self.status == CertificateStatuses.downloadable def save(self, *args, **kwargs): """ After the base save() method finishes, fire the COURSE_CERT_AWARDED signal iff we are saving a record of a learner passing the course. As well as the COURSE_CERT_CHANGED for any save event. """ super(GeneratedCertificate, self).save(*args, **kwargs) COURSE_CERT_CHANGED.send_robust( sender=self.__class__, user=self.user, course_key=self.course_id, mode=self.mode, status=self.status, ) if CertificateStatuses.is_passing_status(self.status): COURSE_CERT_AWARDED.send_robust( sender=self.__class__, user=self.user, course_key=self.course_id, mode=self.mode, status=self.status, )
class CourseOverview(TimeStampedModel): """ Model for storing and caching basic information about a course. This model contains basic course metadata such as an ID, display name, image URL, and any other information that would be necessary to display a course as part of: user dashboard (enrolled courses) course catalog (courses to enroll in) course about (meta data about the course) .. no_pii: """ class Meta(object): app_label = 'course_overviews' # IMPORTANT: Bump this whenever you modify this model and/or add a migration. VERSION = 7 # Cache entry versioning. version = IntegerField() # Course identification id = CourseKeyField(db_index=True, primary_key=True, max_length=255) _location = UsageKeyField(max_length=255) org = TextField(max_length=255, default=u'outdated_entry') display_name = TextField(null=True) display_number_with_default = TextField() display_org_with_default = TextField() # Start/end dates # TODO Remove 'start' & 'end' in removing field in column renaming, DE-1822 start = DateTimeField(null=True) end = DateTimeField(null=True) start_date = DateTimeField(null=True) end_date = DateTimeField(null=True) advertised_start = TextField(null=True) announcement = DateTimeField(null=True) # URLs course_image_url = TextField() social_sharing_url = TextField(null=True) end_of_course_survey_url = TextField(null=True) # Certification data certificates_display_behavior = TextField(null=True) certificates_show_before_end = BooleanField(default=False) cert_html_view_enabled = BooleanField(default=False) has_any_active_web_certificate = BooleanField(default=False) cert_name_short = TextField() cert_name_long = TextField() certificate_available_date = DateTimeField(default=None, null=True) # Grading lowest_passing_grade = DecimalField(max_digits=5, decimal_places=2, null=True) # Access parameters days_early_for_beta = FloatField(null=True) mobile_available = BooleanField(default=False) visible_to_staff_only = BooleanField(default=False) _pre_requisite_courses_json = TextField() # JSON representation of list of CourseKey strings # Enrollment details enrollment_start = DateTimeField(null=True) enrollment_end = DateTimeField(null=True) enrollment_domain = TextField(null=True) invitation_only = BooleanField(default=False) max_student_enrollments_allowed = IntegerField(null=True) # Catalog information catalog_visibility = TextField(null=True) short_description = TextField(null=True) course_video_url = TextField(null=True) effort = TextField(null=True) self_paced = BooleanField(default=False) marketing_url = TextField(null=True) eligible_for_financial_aid = BooleanField(default=True) language = TextField(null=True) history = HistoricalRecords() @classmethod def _create_or_update(cls, course): """ Creates or updates a CourseOverview object from a CourseDescriptor. Does not touch the database, simply constructs and returns an overview from the given course. Arguments: course (CourseDescriptor): any course descriptor object Returns: CourseOverview: created or updated overview extracted from the given course """ from lms.djangoapps.certificates.api import get_active_web_certificate from openedx.core.lib.courses import course_image_url # Workaround for a problem discovered in https://openedx.atlassian.net/browse/TNL-2806. # If the course has a malformed grading policy such that # course._grading_policy['GRADE_CUTOFFS'] = {}, then # course.lowest_passing_grade will raise a ValueError. # Work around this for now by defaulting to None. try: lowest_passing_grade = course.lowest_passing_grade except ValueError: lowest_passing_grade = None display_name = course.display_name start = course.start end = course.end max_student_enrollments_allowed = course.max_student_enrollments_allowed if isinstance(course.id, CCXLocator): from lms.djangoapps.ccx.utils import get_ccx_from_ccx_locator ccx = get_ccx_from_ccx_locator(course.id) display_name = ccx.display_name start = ccx.start end = ccx.due max_student_enrollments_allowed = ccx.max_student_enrollments_allowed course_overview = cls.objects.filter(id=course.id) if course_overview.exists(): log.info(u'Updating course overview for %s.', six.text_type(course.id)) course_overview = course_overview.first() else: log.info(u'Creating course overview for %s.', six.text_type(course.id)) course_overview = cls() course_overview.version = cls.VERSION course_overview.id = course.id course_overview._location = course.location course_overview.org = course.location.org course_overview.display_name = display_name course_overview.display_number_with_default = course.display_number_with_default course_overview.display_org_with_default = course.display_org_with_default course_overview.start = start course_overview.end = end course_overview.advertised_start = course.advertised_start course_overview.announcement = course.announcement course_overview.course_image_url = course_image_url(course) course_overview.social_sharing_url = course.social_sharing_url course_overview.certificates_display_behavior = course.certificates_display_behavior course_overview.certificates_show_before_end = course.certificates_show_before_end course_overview.cert_html_view_enabled = course.cert_html_view_enabled course_overview.has_any_active_web_certificate = (get_active_web_certificate(course) is not None) course_overview.cert_name_short = course.cert_name_short course_overview.cert_name_long = course.cert_name_long course_overview.certificate_available_date = course.certificate_available_date course_overview.lowest_passing_grade = lowest_passing_grade course_overview.end_of_course_survey_url = course.end_of_course_survey_url course_overview.days_early_for_beta = course.days_early_for_beta course_overview.mobile_available = course.mobile_available course_overview.visible_to_staff_only = course.visible_to_staff_only course_overview._pre_requisite_courses_json = json.dumps(course.pre_requisite_courses) course_overview.enrollment_start = course.enrollment_start course_overview.enrollment_end = course.enrollment_end course_overview.enrollment_domain = course.enrollment_domain course_overview.invitation_only = course.invitation_only course_overview.max_student_enrollments_allowed = max_student_enrollments_allowed course_overview.catalog_visibility = course.catalog_visibility course_overview.short_description = CourseDetails.fetch_about_attribute(course.id, 'short_description') course_overview.effort = CourseDetails.fetch_about_attribute(course.id, 'effort') course_overview.course_video_url = CourseDetails.fetch_video_url(course.id) course_overview.self_paced = course.self_paced if not CatalogIntegration.is_enabled(): course_overview.language = course.language return course_overview @classmethod def load_from_module_store(cls, course_id): """ Load a CourseDescriptor, create or update a CourseOverview from it, cache the overview, and return it. Arguments: course_id (CourseKey): the ID of the course overview to be loaded. Returns: CourseOverview: overview of the requested course. Raises: - CourseOverview.DoesNotExist if the course specified by course_id was not found. - IOError if some other error occurs while trying to load the course from the module store. """ log.info( "Attempting to load CourseOverview for course %s from modulestore.", course_id, ) store = modulestore() with store.bulk_operations(course_id): course = store.get_course(course_id) if isinstance(course, CourseDescriptor): course_overview = cls._create_or_update(course) try: with transaction.atomic(): course_overview.save() # Remove and recreate all the course tabs CourseOverviewTab.objects.filter(course_overview=course_overview).delete() CourseOverviewTab.objects.bulk_create([ CourseOverviewTab(tab_id=tab.tab_id, course_overview=course_overview) for tab in course.tabs ]) # Remove and recreate course images CourseOverviewImageSet.objects.filter(course_overview=course_overview).delete() CourseOverviewImageSet.create(course_overview, course) except IntegrityError: # There is a rare race condition that will occur if # CourseOverview.get_from_id is called while a # another identical overview is already in the process # of being created. # One of the overviews will be saved normally, while the # other one will cause an IntegrityError because it tries # to save a duplicate. # (see: https://openedx.atlassian.net/browse/TNL-2854). log.info( "Multiple CourseOverviews for course %s requested " "simultaneously; will only save one.", course_id, ) except Exception: log.exception( "Saving CourseOverview for course %s failed with " "unexpected exception!", course_id, ) raise return course_overview elif course is not None: raise IOError( "Error while loading CourseOverview for course {} " "from the module store: {}", six.text_type(course_id), course.error_msg if isinstance(course, ErrorDescriptor) else six.text_type(course) ) else: log.info( "Could not create CourseOverview for non-existent course: %s", course_id, ) raise cls.DoesNotExist() @classmethod def course_exists(cls, course_id): """ Check whether a course run exists (in CourseOverviews _or_ modulestore). Checks the CourseOverview table first. If it is not there, check the modulestore. Equivalent to, but more efficient than: bool(CourseOverview.get_from_id(course_id)) Arguments: course_id (CourseKey) Returns: bool """ if cls.objects.filter(id=course_id).exists(): return True return modulestore().has_course(course_id) @classmethod def get_from_id(cls, course_id): """ Load a CourseOverview object for a given course ID. First, we try to load the CourseOverview from the database. If it doesn't exist, we load the entire course from the modulestore, create a CourseOverview object from it, and then cache it in the database for future use. Arguments: course_id (CourseKey): the ID of the course overview to be loaded. Returns: CourseOverview: overview of the requested course. Raises: - CourseOverview.DoesNotExist if the course specified by course_id was not found. - IOError if some other error occurs while trying to load the course from the module store. """ try: course_overview = cls.objects.select_related('image_set').get(id=course_id) if course_overview.version < cls.VERSION: # Reload the overview from the modulestore to update the version course_overview = cls.load_from_module_store(course_id) except cls.DoesNotExist: course_overview = None # Regenerate the thumbnail images if they're missing (either because # they were never generated, or because they were flushed out after # a change to CourseOverviewImageConfig. if course_overview and not hasattr(course_overview, 'image_set'): CourseOverviewImageSet.create(course_overview) return course_overview or cls.load_from_module_store(course_id) @classmethod def get_from_ids(cls, course_ids): """ Return a dict mapping course_ids to CourseOverviews. Tries to select all CourseOverviews in one query, then fetches remaining (uncached) overviews from the modulestore. Course IDs for non-existant courses will map to None. Arguments: course_ids (iterable[CourseKey]) Returns: dict[CourseKey, CourseOverview|None] """ overviews = { overview.id: overview for overview in cls.objects.select_related('image_set').filter( id__in=course_ids, version__gte=cls.VERSION ) } for course_id in course_ids: if course_id not in overviews: try: overviews[course_id] = cls.load_from_module_store(course_id) except CourseOverview.DoesNotExist: overviews[course_id] = None return overviews def clean_id(self, padding_char='='): """ Returns a unique deterministic base32-encoded ID for the course. Arguments: padding_char (str): Character used for padding at end of base-32 -encoded string, defaulting to '=' """ return course_metadata_utils.clean_course_key(self.location.course_key, padding_char) @property def location(self): """ Returns the UsageKey of this course. UsageKeyField has a strange behavior where it fails to parse the "run" of a course out of the serialized form of a Mongo Draft UsageKey. This method is a wrapper around _location attribute that fixes the problem by calling map_into_course, which restores the run attribute. """ if self._location.run is None: self._location = self._location.map_into_course(self.id) return self._location @property def number(self): """ Returns this course's number. This is a "number" in the sense of the "course numbers" that you see at lots of universities. For example, given a course "Intro to Computer Science" with the course key "edX/CS-101/2014", the course number would be "CS-101" """ return course_metadata_utils.number_for_course_location(self.location) @property def url_name(self): """ Returns this course's URL name. """ return block_metadata_utils.url_name_for_block(self) @property def display_name_with_default(self): """ Return reasonable display name for the course. """ return block_metadata_utils.display_name_with_default(self) @property def display_name_with_default_escaped(self): """ DEPRECATED: use display_name_with_default Return html escaped reasonable display name for the course. Note: This newly introduced method should not be used. It was only introduced to enable a quick search/replace and the ability to slowly migrate and test switching to display_name_with_default, which is no longer escaped. """ # pylint: disable=line-too-long return block_metadata_utils.display_name_with_default_escaped(self) # xss-lint: disable=python-deprecated-display-name @property def dashboard_start_display(self): """ Return start date to diplay on learner's dashboard, preferably `Course Advertised Start` """ return self.advertised_start or self.start def has_started(self): """ Returns whether the the course has started. """ return course_metadata_utils.has_course_started(self.start) def has_ended(self): """ Returns whether the course has ended. """ return course_metadata_utils.has_course_ended(self.end) def has_marketing_url(self): """ Returns whether the course has marketing url. """ return settings.FEATURES.get('ENABLE_MKTG_SITE') and bool(self.marketing_url) def has_social_sharing_url(self): """ Returns whether the course has social sharing url. """ is_social_sharing_enabled = getattr(settings, 'SOCIAL_SHARING_SETTINGS', {}).get('CUSTOM_COURSE_URLS') return is_social_sharing_enabled and bool(self.social_sharing_url) def starts_within(self, days): """ Returns True if the course starts with-in given number of days otherwise returns False. """ return course_metadata_utils.course_starts_within(self.start, days) @property def start_date_is_still_default(self): """ Checks if the start date set for the course is still default, i.e. .start has not been modified, and .advertised_start has not been set. """ return course_metadata_utils.course_start_date_is_default( self.start, self.advertised_start, ) @property def sorting_score(self): """ Returns a tuple that can be used to sort the courses according the how "new" they are. The "newness" score is computed using a heuristic that takes into account the announcement and (advertised) start dates of the course if available. The lower the number the "newer" the course. """ return course_metadata_utils.sorting_score(self.start, self.advertised_start, self.announcement) @property def start_type(self): """ Returns the type of the course's 'start' field. """ if self.advertised_start: return u'string' elif self.start != DEFAULT_START_DATE: return u'timestamp' else: return u'empty' @property def start_display(self): """ Returns the display value for the course's start date. """ if self.advertised_start: return self.advertised_start elif self.start != DEFAULT_START_DATE: return defaultfilters.date(self.start, "DATE_FORMAT") else: return None def may_certify(self): """ Returns whether it is acceptable to show the student a certificate download link. """ return course_metadata_utils.may_certify_for_course( self.certificates_display_behavior, self.certificates_show_before_end, self.has_ended(), self.certificate_available_date, self.self_paced ) @property def pre_requisite_courses(self): """ Returns a list of ID strings for this course's prerequisite courses. """ return json.loads(self._pre_requisite_courses_json) @pre_requisite_courses.setter def pre_requisite_courses(self, value): """ Django requires there be a setter for this, but it is not necessary for the way we currently use it. Due to the way CourseOverviews are constructed raising errors here will cause a lot of issues. These should not be mutable after construction, so for now we just eat this. """ pass @classmethod def update_select_courses(cls, course_keys, force_update=False): """ A side-effecting method that updates CourseOverview objects for the given course_keys. Arguments: course_keys (list[CourseKey]): Identifies for which courses to return CourseOverview objects. force_update (boolean): Optional parameter that indicates whether the requested CourseOverview objects should be forcefully updated (i.e., re-synched with the modulestore). """ log.info(u'Generating course overview for %d courses.', len(course_keys)) log.debug(u'Generating course overview(s) for the following courses: %s', course_keys) action = CourseOverview.load_from_module_store if force_update else CourseOverview.get_from_id for course_key in course_keys: try: action(course_key) except Exception as ex: # pylint: disable=broad-except log.exception( u'An error occurred while generating course overview for %s: %s', six.text_type(course_key), text_type(ex), ) log.info('Finished generating course overviews.') @classmethod def get_all_courses(cls, orgs=None, filter_=None): """ Return a queryset containing all CourseOverview objects in the database. Arguments: orgs (list[string]): Optional parameter that allows case-insensitive filtering by organization. filter_ (dict): Optional parameter that allows custom filtering. """ # Note: If a newly created course is not returned in this QueryList, # make sure the "publish" signal was emitted when the course was # created. For tests using CourseFactory, use emit_signals=True. course_overviews = CourseOverview.objects.all() if orgs: # In rare cases, courses belonging to the same org may be accidentally assigned # an org code with a different casing (e.g., Harvardx as opposed to HarvardX). # Case-insensitive matching allows us to deal with this kind of dirty data. org_filter = Q() # Avoiding the `reduce()` for more readability, so a no-op filter starter is needed. for org in orgs: org_filter |= Q(org__iexact=org) course_overviews = course_overviews.filter(org_filter) if filter_: course_overviews = course_overviews.filter(**filter_) return course_overviews @classmethod def get_all_course_keys(cls): """ Returns all course keys from course overviews. """ return CourseOverview.objects.values_list('id', flat=True) def is_discussion_tab_enabled(self): """ Returns True if course has discussion tab and is enabled """ tabs = self.tabs.all() # creates circular import; hence explicitly referenced is_discussion_enabled for tab in tabs: if tab.tab_id == "discussion" and django_comment_client.utils.is_discussion_enabled(self.id): return True return False @property def image_urls(self): """ Return a dict with all known URLs for this course image. Current resolutions are: raw = original upload from the user small = thumbnail with dimensions CourseOverviewImageConfig.current().small large = thumbnail with dimensions CourseOverviewImageConfig.current().large If no thumbnails exist, the raw (originally uploaded) image will be returned for all resolutions. """ # This is either the raw image that the course team uploaded, or the # settings.DEFAULT_COURSE_ABOUT_IMAGE_URL if they didn't specify one. raw_image_url = self.course_image_url # Default all sizes to return the raw image if there is no # CourseOverviewImageSet associated with this CourseOverview. This can # happen because we're disabled via CourseOverviewImageConfig. urls = { 'raw': raw_image_url, 'small': raw_image_url, 'large': raw_image_url, } # If we do have a CourseOverviewImageSet, we still default to the raw # images if our thumbnails are blank (might indicate that there was a # processing error of some sort while trying to generate thumbnails). if hasattr(self, 'image_set') and CourseOverviewImageConfig.current().enabled: urls['small'] = self.image_set.small_url or raw_image_url urls['large'] = self.image_set.large_url or raw_image_url return self.apply_cdn_to_urls(urls) @property def pacing(self): """ Returns the pacing for the course. Potential values: self: Self-paced courses instructor: Instructor-led courses """ return 'self' if self.self_paced else 'instructor' @property def closest_released_language(self): """ Returns the language code that most closely matches this course' language and is fully supported by the LMS, or None if there are no fully supported languages that match the target. """ return get_closest_released_language(self.language) if self.language else None def apply_cdn_to_urls(self, image_urls): """ Given a dict of resolutions -> urls, return a copy with CDN applied. If CDN does not exist or is disabled, just returns the original. The URLs that we store in CourseOverviewImageSet are all already top level paths, so we don't need to go through the /static remapping magic that happens with other course assets. We just need to add the CDN server if appropriate. """ cdn_config = AssetBaseUrlConfig.current() if not cdn_config.enabled: return image_urls base_url = cdn_config.base_url return { resolution: self._apply_cdn_to_url(url, base_url) for resolution, url in image_urls.items() } def _apply_cdn_to_url(self, url, base_url): """ Applies a new CDN/base URL to the given URL. If a URL is absolute, we skip switching the host since it could be a hostname that isn't behind our CDN, and we could unintentionally break the URL overall. """ # The URL can't be empty. if not url: return url _, netloc, path, params, query, fragment = urlparse(url) # If this is an absolute URL, just return it as is. It could be a domain # that isn't ours, and thus CDNing it would actually break it. if netloc: return url return urlunparse(('', base_url, path, params, query, fragment)) def __str__(self): """Represent ourselves with the course key.""" return six.text_type(self.id)
class PersistentSubsectionGrade(TimeStampedModel): """ A django model tracking persistent grades at the subsection level. .. no_pii: """ class Meta: app_label = "grades" unique_together = [ # * Specific grades can be pulled using all three columns, # * Progress page can pull all grades for a given (course_id, user_id) # * Course staff can see all grades for a course using (course_id,) ('course_id', 'user_id', 'usage_key'), ] # Allows querying in the following ways: # (modified): find all the grades updated within a certain timespan # (modified, course_id): find all the grades updated within a timespan for a certain course # (modified, course_id, usage_key): find all the grades updated within a timespan for a subsection # in a course # (first_attempted, course_id, user_id): find all attempted subsections in a course for a user # (first_attempted, course_id): find all attempted subsections in a course for all users index_together = [('modified', 'course_id', 'usage_key'), ('first_attempted', 'course_id', 'user_id')] # primary key will need to be large for this table id = UnsignedBigIntAutoField(primary_key=True) # pylint: disable=invalid-name user_id = models.IntegerField(blank=False) course_id = CourseKeyField(blank=False, max_length=255) # note: the usage_key may not have the run filled in for # old mongo courses. Use the full_usage_key property # instead when you want to use/compare the usage_key. usage_key = UsageKeyField(blank=False, max_length=255) # Information relating to the state of content when grade was calculated subtree_edited_timestamp = models.DateTimeField( 'Last content edit timestamp', blank=True, null=True) course_version = models.CharField('Guid of latest course version', blank=True, max_length=255) # earned/possible refers to the number of points achieved and available to achieve. # graded refers to the subset of all problems that are marked as being graded. earned_all = models.FloatField(blank=False) possible_all = models.FloatField(blank=False) earned_graded = models.FloatField(blank=False) possible_graded = models.FloatField(blank=False) # timestamp for the learner's first attempt at content in # this subsection. If null, indicates no attempt # has yet been made. first_attempted = models.DateTimeField(null=True, blank=True) # track which blocks were visible at the time of grade calculation visible_blocks = models.ForeignKey(VisibleBlocks, db_column='visible_blocks_hash', to_field='hashed', on_delete=models.CASCADE) _CACHE_NAMESPACE = 'grades.models.PersistentSubsectionGrade' @property def full_usage_key(self): """ Returns the "correct" usage key value with the run filled in. """ if self.usage_key.run is None: # lint-amnesty, pylint: disable=no-member # pylint: disable=unexpected-keyword-arg,no-value-for-parameter return self.usage_key.replace(course_key=self.course_id) else: return self.usage_key def __str__(self): """ Returns a string representation of this model. """ return ( "{} user: {}, course version: {}, subsection: {} ({}). {}/{} graded, {}/{} all, first_attempted: {}" ).format( type(self).__name__, self.user_id, self.course_version, self.usage_key, self.visible_blocks_id, self.earned_graded, self.possible_graded, self.earned_all, self.possible_all, self.first_attempted, ) @classmethod def prefetch(cls, course_key, users): """ Prefetches grades for the given users in the given course. """ cache_key = cls._cache_key(course_key) get_cache(cls._CACHE_NAMESPACE)[cache_key] = defaultdict(list) cached_grades = get_cache(cls._CACHE_NAMESPACE)[cache_key] queryset = cls.objects.select_related( 'visible_blocks', 'override').filter( user_id__in=[user.id for user in users], course_id=course_key, ) for record in queryset: cached_grades[record.user_id].append(record) @classmethod def clear_prefetched_data(cls, course_key): """ Clears prefetched grades for this course from the RequestCache. """ get_cache(cls._CACHE_NAMESPACE).pop(cls._cache_key(course_key), None) @classmethod def read_grade(cls, user_id, usage_key): """ Reads a grade from database Arguments: user_id: The user associated with the desired grade usage_key: The location of the subsection associated with the desired grade Raises PersistentSubsectionGrade.DoesNotExist if applicable """ return cls.objects.select_related('visible_blocks', 'override').get( user_id=user_id, course_id=usage_key. course_key, # course_id is included to take advantage of db indexes usage_key=usage_key, ) @classmethod def bulk_read_grades(cls, user_id, course_key): """ Reads all grades for the given user and course. Arguments: user_id: The user associated with the desired grades course_key: The course identifier for the desired grades """ try: prefetched_grades = get_cache( cls._CACHE_NAMESPACE)[cls._cache_key(course_key)] try: return prefetched_grades[user_id] except KeyError: # The user's grade is not in the cached dict of subsection grades, # so return an empty list. return [] except KeyError: # subsection grades were not prefetched for the course, so get them from the DB return cls.objects.select_related('visible_blocks', 'override').filter( user_id=user_id, course_id=course_key, ) @classmethod def update_or_create_grade(cls, **params): """ Wrapper for objects.update_or_create. """ cls._prepare_params(params) VisibleBlocks.cached_get_or_create(params['user_id'], params['visible_blocks']) cls._prepare_params_visible_blocks_id(params) # TODO: do we NEED to pop these? first_attempted = params.pop('first_attempted') user_id = params.pop('user_id') usage_key = params.pop('usage_key') grade, _ = cls.objects.update_or_create( user_id=user_id, course_id=usage_key.course_key, usage_key=usage_key, defaults=params, ) # TODO: Remove as part of EDUCATOR-4602. if str(usage_key.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': log.info( 'Created/updated grade ***{}*** for user ***{}*** in course ***{}***' 'for subsection ***{}*** with default params ***{}***'.format( grade, user_id, usage_key.course_key, usage_key, params)) grade.override = PersistentSubsectionGradeOverride.get_override( user_id, usage_key) if first_attempted is not None and grade.first_attempted is None: grade.first_attempted = first_attempted grade.save() cls._emit_grade_calculated_event(grade) return grade @classmethod def bulk_create_grades(cls, grade_params_iter, user_id, course_key): """ Bulk creation of grades. """ if not grade_params_iter: return PersistentSubsectionGradeOverride.prefetch(user_id, course_key) list(map(cls._prepare_params, grade_params_iter)) VisibleBlocks.bulk_get_or_create( user_id, course_key, [params['visible_blocks'] for params in grade_params_iter]) list(map(cls._prepare_params_visible_blocks_id, grade_params_iter)) grades = [ PersistentSubsectionGrade(**params) for params in grade_params_iter ] grades = cls.objects.bulk_create(grades) for grade in grades: cls._emit_grade_calculated_event(grade) return grades @classmethod def _prepare_params(cls, params): """ Prepares the fields for the grade record. """ if not params.get('course_id', None): params['course_id'] = params['usage_key'].course_key params['course_version'] = params.get('course_version', None) or "" params['visible_blocks'] = BlockRecordList.from_list( params['visible_blocks'], params['course_id']) @classmethod def _prepare_params_visible_blocks_id(cls, params): """ Prepares the visible_blocks_id field for the grade record, using the hash of the visible_blocks field. Specifying the hashed field eliminates extra queries to get the VisibleBlocks record. Use this variation of preparing the params when you are sure of the existence of the VisibleBlock. """ params['visible_blocks_id'] = params['visible_blocks'].hash_value del params['visible_blocks'] @staticmethod def _emit_grade_calculated_event(grade): events.subsection_grade_calculated(grade) @classmethod def _cache_key(cls, course_id): return f"subsection_grades_cache.{course_id}"