""" Django model to store the "course index" data """ from bson.objectid import ObjectId from django.contrib.auth import get_user_model from django.db import models from opaque_keys.edx.locator import CourseLocator, LibraryLocator from opaque_keys.edx.django.models import LearningContextKeyField from simple_history.models import HistoricalRecords from xmodule.modulestore import ModuleStoreEnum from xmodule.util.misc import get_library_or_course_attribute User = get_user_model() 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": get_library_or_course_attribute(self.course_id), "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)