Exemple #1
0
class StudentModule(models.Model):
    '''Mocks the courseware.models.StudentModule

    Keeps student state for a particular XBlock usage and particular student.
    Called Module since it was originally used for XModule state.

    class attributes declared in StudentModule but not yet
    needed for mocking are remarked out with a '#!'
    They are here to
    A) Help understand context of the class without requiring opening the
       courseware/models.py file
    B) Be available to quickly update this mock when needed
    '''
    #! objects = ChunkingManager()

    #! id = UnsignedBigIntAutoField(primary_key=True)  # pylint: disable=invalid-name

    ## The XBlock/XModule type (e.g. "problem")
    #! module_type = models.CharField(max_length=32, db_index=True)

    # Key used to share state. This is the XBlock usage_id
    #! module_state_key = LocationKeyField(max_length=255, db_index=True, db_column='module_id')
    # TODO: Review the most appropriate on_delete behaviour
    student = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)

    # The learning context of the usage_key (usually a course ID, but may be a library or something else)
    course_id = LearningContextKeyField(max_length=255, db_index=True)

    #! class Meta(object):
    #!     app_label = "courseware"
    #!     unique_together = (('student', 'module_state_key', 'course_id'),)

    #! # Internal state of the object
    #! state = models.TextField(null=True, blank=True)

    #! # Grade, and are we done?
    #! grade = models.FloatField(null=True, blank=True, db_index=True)
    #! max_grade = models.FloatField(null=True, blank=True)
    #! DONE_TYPES = (
    #!     (u'na', u'NOT_APPLICABLE'),
    #!     (u'f', u'FINISHED'),
    #!     (u'i', u'INCOMPLETE'),
    #!  )
    #! done = models.CharField(max_length=8, choices=DONE_TYPES, default='na', db_index=True)

    # the production model sets 'auto_now_add=True' andn 'db_index=True'
    created = models.DateTimeField()
    modified = models.DateTimeField()
Exemple #2
0
class DiscussionTopicLink(models.Model):
    """
    A model linking discussion topics ids to the part of a course they are linked to.
    """
    context_key = LearningContextKeyField(
        db_index=True,
        max_length=255,
        # Translators: A key specifying a course, library, program,
        # website, or some other collection of content where learning
        # happens.
        verbose_name=_("Learning Context Key"),
        help_text=_(
            "Context key for context in which this discussion topic exists."))
    usage_key = UsageKeyField(
        db_index=True,
        max_length=255,
        null=True,
        blank=True,
        help_text=
        _("Usage key for in-context discussion topic. Set to null for course-level topics."
          ))
    title = models.CharField(max_length=255,
                             help_text=_("Title for discussion topic."))
    group = models.ForeignKey(CourseUserGroup,
                              null=True,
                              blank=True,
                              on_delete=models.SET_NULL,
                              help_text=_("Group for divided discussions."))
    provider_id = models.CharField(
        max_length=32, help_text=_("Provider id for discussion provider."))
    external_id = models.CharField(
        db_index=True,
        max_length=255,
        help_text=
        _("Discussion context ID in external forum provider. e.g. commentable_id for cs_comments_service."
          ))
    enabled_in_context = models.BooleanField(
        default=True,
        help_text=_(
            "Whether this topic should be shown in-context in the course."))

    def __str__(self):
        return (
            f'DiscussionTopicLink('
            f'context_key="{self.context_key}", usage_key="{self.usage_key}", title="{self.title}", '
            f'group={self.group}, provider_id="{self.provider_id}", external_id="{self.external_id}", '
            f'enabled_in_context={self.enabled_in_context}'
            f')')
Exemple #3
0
class LearningContext(TimeStampedModel):
    """
    These are used to group Learning Sequences so that many of them can be
    pulled at once. We use this instead of a foreign key to CourseOverview
    because this table can contain things that are not courses.

    It is okay to make a foreign key against this table.
    """
    id = models.BigAutoField(primary_key=True)
    context_key = LearningContextKeyField(max_length=255,
                                          db_index=True,
                                          unique=True,
                                          null=False)
    title = models.CharField(max_length=255)
    published_at = models.DateTimeField(null=False)
    published_version = models.CharField(max_length=255)

    class Meta:
        indexes = [models.Index(fields=['-published_at'])]
Exemple #4
0
class DiscussionsConfiguration(TimeStampedModel):
    """
    Associates a learning context with discussion provider and configuration
    """

    context_key = LearningContextKeyField(
        primary_key=True,
        db_index=True,
        unique=True,
        max_length=255,
        # Translators: A key specifying a course, library, program,
        # website, or some other collection of content where learning
        # happens.
        verbose_name=_("Learning Context Key"),
    )
    enabled = models.BooleanField(
        default=True,
        help_text=
        _("If disabled, the discussions in the associated learning context/course will be disabled."
          ))
    lti_configuration = models.ForeignKey(
        LtiConfiguration,
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        help_text=_("The LTI configuration data for this context/provider."),
    )
    plugin_configuration = JSONField(
        blank=True,
        default={},
        help_text=_(
            "The plugin configuration data for this context/provider."),
    )
    provider_type = models.CharField(
        blank=False,
        max_length=100,
        verbose_name=_("Discussion provider"),
        help_text=_("The discussion tool/provider's id"),
    )
    history = HistoricalRecords()

    def clean(self):
        """
        Validate the model

        Currently, this only support courses, this can be extended
        whenever discussions are available in other contexts
        """
        if not CourseOverview.course_exists(self.context_key):
            raise ValidationError(
                'Context Key should be an existing learning context.')

    def __str__(self):
        return "DiscussionsConfiguration(context_key='{context_key}', provider='{provider}', enabled={enabled})".format(
            context_key=self.context_key,
            provider=self.provider_type,
            enabled=self.enabled,
        )

    @classmethod
    def is_enabled(cls, context_key: CourseKey) -> bool:
        """
        Check if there is an active configuration for a given course key

        Default to False, if no configuration exists
        """
        configuration = cls.get(context_key)
        return configuration.enabled

    # pylint: disable=undefined-variable
    @classmethod
    def get(cls, context_key: CourseKey) -> cls:
        """
        Lookup a model by context_key
        """
        try:
            configuration = cls.objects.get(context_key=context_key)
        except cls.DoesNotExist:
            configuration = cls(context_key=context_key, enabled=False)
        return configuration

    # pylint: enable=undefined-variable

    @property
    def available_providers(self) -> list[str]:
        return ProviderFilter.current(
            course_key=self.context_key).available_providers

    @classmethod
    def get_available_providers(cls, context_key: CourseKey) -> list[str]:
        return ProviderFilter.current(
            course_key=context_key).available_providers
Exemple #5
0
class StudentModule(models.Model):
    """
    Keeps student state for a particular XBlock usage and particular student.

    Called Module since it was originally used for XModule state.

    .. no_pii:
    """
    objects = ChunkingManager()

    id = UnsignedBigIntAutoField(primary_key=True)  # pylint: disable=invalid-name

    ## The XBlock/XModule type (e.g. "problem")
    module_type = models.CharField(max_length=32, db_index=True)

    # Key used to share state. This is the XBlock usage_id
    module_state_key = UsageKeyField(max_length=255, db_column='module_id')
    student = models.ForeignKey(User,
                                db_index=True,
                                db_constraint=False,
                                on_delete=models.CASCADE)

    # The learning context of the usage_key (usually a course ID, but may be a library or something else)
    course_id = LearningContextKeyField(max_length=255, db_index=True)

    class Meta:
        app_label = "courseware"
        unique_together = (('student', 'module_state_key', 'course_id'), )
        indexes = [
            models.Index(fields=['module_state_key', 'grade', 'student'],
                         name="courseware_stats")
        ]

    # Internal state of the object
    state = models.TextField(null=True, blank=True)

    # Grade, and are we done?
    grade = models.FloatField(null=True, blank=True, db_index=True)
    max_grade = models.FloatField(null=True, blank=True)
    DONE_TYPES = (
        ('na', 'NOT_APPLICABLE'),
        ('f', 'FINISHED'),
        ('i', 'INCOMPLETE'),
    )
    done = models.CharField(max_length=8, choices=DONE_TYPES, default='na')

    created = models.DateTimeField(auto_now_add=True, db_index=True)
    modified = models.DateTimeField(auto_now=True, db_index=True)

    @classmethod
    def all_submitted_problems_read_only(cls, course_id):
        """
        Return all model instances that correspond to problems that have been
        submitted for a given course. So module_type='problem' and a non-null
        grade. Use a read replica if one exists for this environment.
        """
        queryset = cls.objects.filter(course_id=course_id,
                                      module_type='problem',
                                      grade__isnull=False)
        if "read_replica" in settings.DATABASES:
            return queryset.using("read_replica")
        else:
            return queryset

    def __repr__(self):
        return 'StudentModule<{!r}>'.format({
            'course_id': self.course_id,
            'module_type': self.module_type,
            # We use the student_id instead of username to avoid a database hop.
            # This can actually matter in cases where we're logging many of
            # these (e.g. on a broken progress page).
            'student_id': self.student_id,
            'module_state_key': self.module_state_key,
            'state': str(self.state)[:20],
        })

    def __str__(self):
        return str(repr(self))

    @classmethod
    def get_state_by_params(cls,
                            course_id,
                            module_state_keys,
                            student_id=None):
        """
        Return all model instances that correspond to a course and module keys.

        Student ID is optional keyword argument, if provided it narrows down the instances.
        """
        module_states = cls.objects.filter(
            course_id=course_id, module_state_key__in=module_state_keys)
        if student_id:
            module_states = module_states.filter(student_id=student_id)
        return module_states

    @classmethod
    def save_state(cls, student, course_id, module_state_key, defaults):  # lint-amnesty, pylint: disable=missing-function-docstring
        if not student.is_authenticated:
            return
        else:
            cls.objects.update_or_create(
                student=student,
                course_id=course_id,
                module_state_key=module_state_key,
                defaults=defaults,
            )
Exemple #6
0
class BlockCompletion(TimeStampedModel, models.Model):
    """
    Track completion of completable blocks.

    A completion is unique for each (user, context_key, block_key).

    The block_type field is included separately from the block_key to
    facilitate distinct aggregations of the completion of particular types of
    block.

    The completion value is stored as a float in the range [0.0, 1.0], and all
    calculations are performed on this float, though current practice is to
    only track binary completion, where 1.0 indicates that the block is
    complete, and 0.0 indicates that the block is incomplete.
    """
    id = BigAutoField(primary_key=True)  # pylint: disable=invalid-name
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    context_key = LearningContextKeyField(max_length=255,
                                          db_column="course_key")

    # note: this usage key may not have the run filled in for
    # old mongo courses.  Use the full_block_key property
    # instead when you want to use/compare the usage_key.
    block_key = UsageKeyField(max_length=255)
    block_type = models.CharField(max_length=64)
    completion = models.FloatField(validators=[validate_percent])

    objects = BlockCompletionManager()

    @property
    def full_block_key(self):
        """
        Returns the "correct" usage key value with the run filled in.
        This is only necessary for block keys from old mongo courses, which
        didn't include the run information in the block usage key.
        """
        if self.block_key.context_key.is_course and self.block_key.run is None:
            # pylint: disable=unexpected-keyword-arg, no-value-for-parameter
            return self.block_key.replace(course_key=self.context_key)
        return self.block_key

    @classmethod
    def get_learning_context_completions(cls, user, context_key):
        """
        Returns a dictionary mapping BlockKeys to completion values for all
        BlockCompletion records for the given user and learning context key.

        Return value:
            dict[BlockKey] = float
        """
        user_completions = cls.user_learning_context_completion_queryset(
            user, context_key)
        return cls.completion_by_block_key(user_completions)

    @classmethod
    def user_learning_context_completion_queryset(cls, user, context_key):
        """
        Returns a Queryset of completions for a given user and context_key.
        """
        return cls.objects.filter(user=user, context_key=context_key)

    @classmethod
    def latest_blocks_completed_all_courses(cls, user):
        """
        Returns a dictionary mapping course_keys to a tuple containing
        the block_key and modified time of the most recently modified
        completion for the course.

        This only returns results for courses and not other learning context
        types.

        Return value:
            {course_key: (modified_date, block_key)}
        """

        # Per the Django docs, dictionary params are not supported with the SQLite backend;
        # with this backend, you must pass parameters as a list. We use SQLite for unit tests,
        # so the same parameter is included twice in the parameter list below, rather than
        # including it in a dictionary once.
        latest_completions_by_course = cls.objects.raw(
            '''
            SELECT
                cbc.id AS id,
                cbc.course_key AS course_key,
                cbc.block_key AS block_key,
                cbc.modified AS modified
            FROM
                completion_blockcompletion cbc
            JOIN (
                SELECT
                     course_key,
                     MAX(modified) AS modified
                FROM
                     completion_blockcompletion
                WHERE
                     user_id = %s
                GROUP BY
                     course_key
            ) latest
            ON
                cbc.course_key = latest.course_key AND
                cbc.modified = latest.modified
            WHERE
                user_id = %s
            ;
            ''', [user.id, user.id])
        try:
            return {
                completion.context_key:
                (completion.modified, completion.block_key)
                for completion in latest_completions_by_course
                if completion.context_key.is_course
            }
        except KeyError:
            # Iteration of the queryset above will always fail
            # with a KeyError if the queryset is empty
            return {}

    @classmethod
    def get_latest_block_completed(cls, user, context_key):
        """
        Returns a BlockCompletion Object for the last modified user/context_key mapping,
        or None if no such BlockCompletion exists.

        Return value:
            obj: block completion
        """
        try:
            latest_block_completion = cls.user_learning_context_completion_queryset(
                user, context_key).latest()  # pylint: disable=no-member
        except cls.DoesNotExist:
            return None
        return latest_block_completion

    @staticmethod
    def completion_by_block_key(completion_iterable):
        """
        Return value:
            A dict mapping the full block key of a completion record to the completion value
            for each BlockCompletion object given in completion_iterable.  Each BlockKey is
            corrected to have the run field filled in via the BlockCompletion.context_key field.
        """
        return {
            completion.full_block_key: completion.completion
            for completion in completion_iterable
        }

    class Meta:
        index_together = [
            ('context_key', 'block_type', 'user'),
            ('user', 'context_key', 'modified'),
        ]

        unique_together = [('context_key', 'block_key', 'user')]
        get_latest_by = 'modified'

    def __unicode__(self):
        return 'BlockCompletion: {username}, {context_key}, {block_key}: {completion}'.format(
            username=self.user.username,
            context_key=self.context_key,
            block_key=self.block_key,
            completion=self.completion,
        )
Exemple #7
0
class DiscussionsConfiguration(TimeStampedModel):
    """
    Associates a learning context with discussion provider and configuration
    """

    context_key = LearningContextKeyField(
        primary_key=True,
        db_index=True,
        unique=True,
        max_length=255,
        # Translators: A key specifying a course, library, program,
        # website, or some other collection of content where learning
        # happens.
        verbose_name=_("Learning Context Key"),
    )
    enabled = models.BooleanField(
        default=True,
        help_text=
        _("If disabled, the discussions in the associated learning context/course will be disabled."
          ))
    lti_configuration = models.ForeignKey(
        LtiConfiguration,
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        help_text=_("The LTI configuration data for this context/provider."),
    )
    enable_in_context = models.BooleanField(
        default=True,
        help_text=_(
            "If enabled, discussion topics will be created for each non-graded unit in the course. "
            "A UI for discussions will show up with each unit."))
    enable_graded_units = models.BooleanField(
        default=False,
        help_text=
        _("If enabled, discussion topics will be created for graded units as well."
          ))
    unit_level_visibility = models.BooleanField(
        default=False,
        help_text=
        _("If enabled, discussions will need to be manually enabled for each unit."
          ))
    plugin_configuration = JSONField(
        blank=True,
        default={},
        help_text=_(
            "The plugin configuration data for this context/provider."),
    )
    provider_type = models.CharField(
        blank=False,
        max_length=100,
        verbose_name=_("Discussion provider"),
        help_text=_("The discussion tool/provider's id"),
        default=DEFAULT_PROVIDER_TYPE,
    )
    history = HistoricalRecords()

    def clean(self):
        """
        Validate the model.
        Currently, this only support courses, this can be extended
        whenever discussions are available in other contexts
        """
        if not CourseOverview.course_exists(self.context_key):
            raise ValidationError(
                'Context Key should be an existing learning context.')

    def __str__(self):
        return "DiscussionsConfiguration(context_key='{context_key}', provider='{provider}', enabled={enabled})".format(
            context_key=self.context_key,
            provider=self.provider_type,
            enabled=self.enabled,
        )

    def supports_in_context_discussions(self):
        """
        Returns is the provider supports in-context discussions
        """
        return AVAILABLE_PROVIDER_MAP.get(self.provider_type, {}).get(
            'supports_in_context_discussions', False)

    def supports(self, feature: str) -> bool:
        """
        Check if the provider supports some feature
        """
        features = AVAILABLE_PROVIDER_MAP.get(
            self.provider_type)['features'] or []
        has_support = bool(feature in features)
        return has_support

    def supports_lti(self) -> bool:
        """Returns a boolean indicating if the provider supports lti discussion view."""
        return AVAILABLE_PROVIDER_MAP.get(self.provider_type,
                                          {}).get('supports_lti', False)

    @classmethod
    def is_enabled(cls, context_key: CourseKey) -> bool:
        """
        Check if there is an active configuration for a given course key

        Default to False, if no configuration exists
        """
        configuration = cls.get(context_key)
        return configuration.enabled

    @classmethod
    def get(cls: Type[T], context_key: CourseKey) -> T:
        """
        Lookup a model by context_key
        """
        try:
            configuration = cls.objects.get(context_key=context_key)
        except cls.DoesNotExist:
            configuration = cls(
                context_key=context_key,
                enabled=DEFAULT_CONFIG_ENABLED,
                provider_type=DEFAULT_PROVIDER_TYPE,
            )
        return configuration

    @property
    def available_providers(self) -> List[str]:
        return ProviderFilter.current(
            course_key=self.context_key).available_providers

    @classmethod
    def get_available_providers(cls, context_key: CourseKey) -> List[str]:
        return ProviderFilter.current(
            course_key=context_key).available_providers

    @classmethod
    def lti_discussion_enabled(cls, course_key: CourseKey) -> bool:
        """
        Checks if LTI discussion is enabled for this course.

        Arguments:
            course_key: course locator.
        Returns:
            Boolean indicating weather or not this course has lti discussion enabled.
        """
        discussion_provider = cls.get(course_key)
        return (discussion_provider.enabled
                and discussion_provider.supports_lti()
                and discussion_provider.lti_configuration is not None)
Exemple #8
0
class SplitModulestoreCourseIndex(models.Model):
    """
    A "course index" for a course in "split modulestore."

    This model/table mostly stores the current version of each course.
    (Well, twice for each course - "draft" and "published" branch versions are
    tracked separately.)

    This MySQL table / django model is designed to replace the "active_versions"
    MongoDB collection. They contain the same information.

    It also stores the "wiki_slug" to facilitate looking up a course
    by it's wiki slug, which is required due to the nuances of the
    django-wiki integration.

    .. no_pii:
    """
    # For compatibility with MongoDB, each course index must have an ObjectId. We still have an integer primary key too.
    objectid = models.CharField(max_length=24,
                                null=False,
                                blank=False,
                                unique=True)

    # The ID of this course (or library). Must start with "course-v1:" or "library-v1:"
    course_id = LearningContextKeyField(max_length=255,
                                        db_index=True,
                                        unique=True,
                                        null=False)
    # Extract the "org" value from the course_id key so that we can search by org.
    # This gets set automatically by clean()
    org = models.CharField(max_length=255, db_index=True)

    # Version fields: The ObjectId of the current entry in the "structures" collection, for this course.
    # The version is stored separately for each "branch".
    # Note that there are only three branch names allowed. Draft/published are used for courses, while "library" is used
    # for content libraries.

    # ModuleStoreEnum.BranchName.draft = 'draft-branch'
    draft_version = models.CharField(max_length=24, null=False, blank=True)
    # ModuleStoreEnum.BranchName.published = 'published-branch'
    published_version = models.CharField(max_length=24, null=False, blank=True)
    # ModuleStoreEnum.BranchName.library = 'library'
    library_version = models.CharField(max_length=24, null=False, blank=True)

    # Wiki slug for this course
    wiki_slug = models.CharField(max_length=255, db_index=True, blank=True)

    # Base store - whether the "structures" and "definitions" data are in MongoDB or object storage (S3)
    BASE_STORE_MONGO = "mongodb"
    BASE_STORE_DJANGO = "django"
    BASE_STORE_CHOICES = [
        (BASE_STORE_MONGO,
         "MongoDB"),  # For now, MongoDB is the only implemented option
        (BASE_STORE_DJANGO, "Django - not implemented yet"),
    ]
    base_store = models.CharField(max_length=20,
                                  blank=False,
                                  choices=BASE_STORE_CHOICES)

    # Edit history:
    # ID of the user that made the latest edit. This is not a ForeignKey because some values (like
    # ModuleStoreEnum.UserID.*) are not real user IDs.
    edited_by_id = models.IntegerField(null=True)
    edited_on = models.DateTimeField()
    # last_update is different from edited_on, and is used only to prevent collisions?
    last_update = models.DateTimeField()

    # Keep track of the history of this table:
    history = HistoricalRecords()

    def __str__(self):
        return f"Course Index ({self.course_id})"

    class Meta:
        ordering = ["course_id"]
        verbose_name_plural = "Split modulestore course indexes"

    def as_v1_schema(self):
        """ Return in the same format as was stored in MongoDB """
        versions = {}
        for branch in ("draft", "published", "library"):
            # The current version of this branch, a hex-encoded ObjectID - or an empty string:
            version_str = getattr(self, f"{branch}_version")
            if version_str:
                versions[getattr(ModuleStoreEnum.BranchName,
                                 branch)] = ObjectId(version_str)
        return {
            "_id": ObjectId(self.objectid),
            "org": self.course_id.org,
            "course": self.course_id.course,
            "run": self.course_id.run,  # pylint: disable=no-member
            "edited_by": self.edited_by_id,
            "edited_on": self.edited_on,
            "last_update": self.last_update,
            "versions": versions,
            "schema_version":
            1,  # This matches schema version 1, see SplitMongoModuleStore.SCHEMA_VERSION
            "search_targets": {
                "wiki_slug": self.wiki_slug
            },
        }

    @staticmethod
    def fields_from_v1_schema(values):
        """ Convert the MongoDB-style dict shape to a dict of fields that match this model """
        if values[
                "run"] == LibraryLocator.RUN and ModuleStoreEnum.BranchName.library in values[
                    "versions"]:
            # This is a content library:
            locator = LibraryLocator(org=values["org"],
                                     library=values["course"])
        else:
            # This is a course:
            locator = CourseLocator(org=values["org"],
                                    course=values["course"],
                                    run=values["run"])
        result = {
            "course_id": locator,
            "org": values["org"],
            "edited_by_id": values["edited_by"],
            "edited_on": values["edited_on"],
            "base_store": SplitModulestoreCourseIndex.BASE_STORE_MONGO,
        }
        if "_id" in values:
            result["objectid"] = str(
                values["_id"])  # Convert ObjectId to its hex representation
        if "last_update" in values:
            result["last_update"] = values["last_update"]
        if "search_targets" in values and "wiki_slug" in values[
                "search_targets"]:
            result["wiki_slug"] = values["search_targets"]["wiki_slug"]
        for branch in ("draft", "published", "library"):
            version = values["versions"].get(
                getattr(ModuleStoreEnum.BranchName, branch))
            if version:
                result[f"{branch}_version"] = str(
                    version)  # Convert version from ObjectId to hex string
        return result

    @staticmethod
    def field_name_for_branch(branch_name):
        """ Given a full branch name, get the name of the field in this table that stores that branch's version """
        if branch_name == ModuleStoreEnum.BranchName.draft:
            return "draft_version"
        if branch_name == ModuleStoreEnum.BranchName.published:
            return "published_version"
        if branch_name == ModuleStoreEnum.BranchName.library:
            return "library_version"
        raise ValueError(f"Unknown branch name: {branch_name}")

    def clean(self):
        """
        Validation for this model
        """
        super().clean()
        # Check that course_id is a supported type:
        course_id_str = str(self.course_id)
        if not course_id_str.startswith(
                "course-v1:") and not course_id_str.startswith("library-v1:"):
            raise ValueError(
                f"Split modulestore cannot store course[like] object with key {course_id_str}"
                " - only course-v1/library-v1 prefixed keys are supported.")
        # Set the "org" field automatically - ensure it always matches the "org" in the course_id
        self.org = self.course_id.org

    def save(self, *args, **kwargs):
        """ Save this model """
        # Override to ensure that full_clean()/clean() is always called, so that the checks in clean() above are run.
        # But don't validate_unique(), it just runs extra queries and the database enforces it anyways.
        self.full_clean(validate_unique=False)
        return super().save(*args, **kwargs)