From 0c30eb9482593f82274bc02904f1d891040fee8e Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Sat, 13 Aug 2022 21:32:26 -0400 Subject: [PATCH 01/33] refactor: change Item to Item + Component --- .../management/commands/load_course_data.py | 38 +-- .../staticassets/migrations/0001_initial.py | 6 +- .../contrib/staticassets/models.py | 8 +- .../core/itemstore/migrations/0001_initial.py | 119 ++++++-- openedx_learning/core/itemstore/models.py | 278 +++++++++++++----- openedx_learning/core/itemstore/models_api.py | 12 +- 6 files changed, 325 insertions(+), 136 deletions(-) diff --git a/olx_importer/management/commands/load_course_data.py b/olx_importer/management/commands/load_course_data.py index 5e7cf833..e814b806 100644 --- a/olx_importer/management/commands/load_course_data.py +++ b/olx_importer/management/commands/load_course_data.py @@ -13,9 +13,9 @@ has really happened between what's currently stored/published for a particular item and the new value we want to set? For Content that's easy, because we have actual hashes of the data. But it's not clear how that would work for something -like an ItemVersion. We'd have to have some kind of mechanism where every app +like an ComponentVersion. We'd have to have some kind of mechanism where every app that wants to attach data gets to answer the question of "has anything changed?" -in order to decide if we really make a new ItemVersion or not. +in order to decide if we really make a new ComponentVersion or not. """ from collections import defaultdict, Counter from datetime import datetime, timezone @@ -28,12 +28,12 @@ from django.core.management.base import BaseCommand from django.db import transaction -from openedx_learning.contrib.staticassets.models import Asset, ItemVersionAsset +from openedx_learning.contrib.staticassets.models import Asset, ComponentVersionAsset from openedx_learning.core.publishing.models import ( LearningContext, LearningContextVersion ) from openedx_learning.core.itemstore.models import ( - Content, Item, ItemVersion, LearningContextVersionItemVersion + Content, Component, ComponentVersion, LearningContextVersionComponentVersion ) from openedx_learning.lib.fields import create_hash_digest @@ -172,7 +172,7 @@ def import_static_assets(self, course_data_path, item_raw_id_cache, now): def import_block_type(self, block_type, content_path, static_asset_paths_to_atom_ids, item_raw_id_cache, now): - items_found = 0 + components_found = 0 # Find everything that looks like a reference to a static file appearing # in attribute quotes, stripping off the querystring at the end. This is @@ -181,14 +181,14 @@ def import_block_type(self, block_type, content_path, static_asset_paths_to_atom static_files_regex = r"""['"]\/static\/(.+?)["'\?]""" for xml_file_path in content_path.iterdir(): - items_found += 1 + components_found += 1 identifier = xml_file_path.stem # Find or create the Item itself - item, _created = Item.objects.get_or_create( + component, _created = Component.objects.get_or_create( learning_context=self.learning_context, namespace='xblock.v1', - identifier=identifier, + identifier=f"{block_type}+{identifier}", defaults = { 'created': now, 'modified': now, @@ -199,10 +199,10 @@ def import_block_type(self, block_type, content_path, static_asset_paths_to_atom data_bytes = xml_file_path.read_bytes() hash_digest = create_hash_digest(data_bytes) data_str = codecs.decode(data_bytes, 'utf-8') - mime_type = f'application/vnd.openedx.xblock.v1.{block_type}+xml' content, _created = Content.objects.get_or_create( learning_context=self.learning_context, - mime_type=mime_type, + type='application', + sub_type='vnd.openedx.xblock.v1.{block_type}+xml', hash_digest=hash_digest, defaults = dict( data=data_bytes, @@ -219,18 +219,18 @@ def import_block_type(self, block_type, content_path, static_asset_paths_to_atom display_name = block_root.attrib.get('display_name', "") - # Create the ItemVersion - item_version = ItemVersion.objects.create( - item=item, - title=display_name, + # Create the ComponentVersion + component_version = ComponentVersion.objects.create( + component=component, + # title=display_name, created=now, ) - item_version.contents.add(content) + component_version.contents.add(content) - LearningContextVersionItemVersion.objects.create( + LearningContextVersionComponentVersion.objects.create( learning_context_version=self.new_lcv, - item_version=item_version, - item=item, + component_version=component_version, + component=component, ) - print(f"{block_type}: {items_found}") + print(f"{block_type}: {components_found}") diff --git a/openedx_learning/contrib/staticassets/migrations/0001_initial.py b/openedx_learning/contrib/staticassets/migrations/0001_initial.py index c611f5fe..09947f78 100644 --- a/openedx_learning/contrib/staticassets/migrations/0001_initial.py +++ b/openedx_learning/contrib/staticassets/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.10 on 2022-08-07 17:00 +# Generated by Django 3.2.13 on 2022-08-12 14:46 from django.db import migrations, models import django.db.models.deletion @@ -20,9 +20,9 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='ItemVersionAsset', + name='ComponentVersionAsset', fields=[ - ('item_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='itemstore.itemversion')), + ('component_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='itemstore.componentversion')), ('asset', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='staticassets.asset')), ], options={ diff --git a/openedx_learning/contrib/staticassets/models.py b/openedx_learning/contrib/staticassets/models.py index 613cbcdf..d41e7c69 100644 --- a/openedx_learning/contrib/staticassets/models.py +++ b/openedx_learning/contrib/staticassets/models.py @@ -3,14 +3,14 @@ images be treated separately from downloads, e.g. ImageItem, DownloadableItem Thought: These separate extension models aren't like subclasses, but more like -aspects. So it's not like an ImageItemVersion is a subclass of DownloadableItem, -but that some ItemVersions will have a downloadable file aspect, and some will +aspects. So it's not like an ImageComponentVersion is a subclass of DownloadableItem, +but that some ComponentVersions will have a downloadable file aspect, and some will have an image aspect, and all those that have the image aspect will also have the downloadable piece. """ from django.db import models -from openedx_learning.core.itemstore.models_api import ItemVersionDataMixin +from openedx_learning.core.itemstore.models_api import ComponentVersionDataMixin class Asset(models.Model): @@ -19,5 +19,5 @@ class Asset(models.Model): """ pass -class ItemVersionAsset(ItemVersionDataMixin): +class ComponentVersionAsset(ComponentVersionDataMixin): asset = models.ForeignKey(Asset, on_delete=models.RESTRICT, null=False) diff --git a/openedx_learning/core/itemstore/migrations/0001_initial.py b/openedx_learning/core/itemstore/migrations/0001_initial.py index 51b397ad..adfdd2ae 100644 --- a/openedx_learning/core/itemstore/migrations/0001_initial.py +++ b/openedx_learning/core/itemstore/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.10 on 2022-08-07 17:00 +# Generated by Django 3.2.13 on 2022-08-12 14:46 import django.core.validators from django.db import migrations, models @@ -16,24 +16,31 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Content', + name='Component', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('hash_digest', models.CharField(editable=False, max_length=40)), - ('type', models.CharField(max_length=127)), - ('sub_type', models.CharField(max_length=127)), - ('size', models.PositiveBigIntegerField(validators=[django.core.validators.MaxValueValidator(10000000)])), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('namespace', models.CharField(max_length=100)), + ('identifier', models.CharField(max_length=255)), ('created', models.DateTimeField()), - ('data', models.BinaryField(max_length=10000000)), + ('modified', models.DateTimeField()), ('learning_context', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='publishing.learningcontext')), ], ), + migrations.CreateModel( + name='ComponentVersion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created', models.DateTimeField()), + ('component', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itemstore.component')), + ], + ), migrations.CreateModel( name='Item', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), - ('namespace', models.CharField(max_length=100)), ('identifier', models.CharField(max_length=255)), ('created', models.DateTimeField()), ('modified', models.DateTimeField()), @@ -45,8 +52,8 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), - ('title', models.CharField(blank=True, max_length=1000, null=True)), - ('created', models.DateTimeField()), + ('title', models.CharField(blank=True, max_length=1000)), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itemstore.item')), ], ), migrations.CreateModel( @@ -59,55 +66,107 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='ItemVersionContent', + name='LearningContextVersionComponentVersion', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('identifier', models.CharField(max_length=255)), - ('content', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='itemstore.content')), + ('component', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='itemstore.component')), + ('component_version', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='itemstore.componentversion')), + ('learning_context_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='publishing.learningcontextversion')), + ], + ), + migrations.CreateModel( + name='ItemVersionComponentVersion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order_num', models.PositiveIntegerField()), + ('component_version', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='itemstore.componentversion')), ('item_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itemstore.itemversion')), ], ), migrations.AddField( model_name='itemversion', - name='contents', - field=models.ManyToManyField(related_name='item_versions', through='itemstore.ItemVersionContent', to='itemstore.Content'), + name='item_components', + field=models.ManyToManyField(related_name='item_versions', through='itemstore.ItemVersionComponentVersion', to='itemstore.ComponentVersion'), ), migrations.AddField( model_name='itemversion', - name='item', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itemstore.item'), + name='learning_context_versions', + field=models.ManyToManyField(related_name='item_versions', through='itemstore.LearningContextVersionItemVersion', to='publishing.LearningContextVersion'), + ), + migrations.CreateModel( + name='Content', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hash_digest', models.CharField(editable=False, max_length=40)), + ('type', models.CharField(max_length=127)), + ('sub_type', models.CharField(max_length=127)), + ('size', models.PositiveBigIntegerField(validators=[django.core.validators.MaxValueValidator(10000000)])), + ('created', models.DateTimeField()), + ('data', models.BinaryField(max_length=10000000)), + ('learning_context', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='publishing.learningcontext')), + ], + ), + migrations.CreateModel( + name='ComponentVersionContent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('identifier', models.CharField(max_length=255)), + ('component_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itemstore.componentversion')), + ('content', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='itemstore.content')), + ], ), migrations.AddField( - model_name='itemversion', + model_name='componentversion', + name='contents', + field=models.ManyToManyField(related_name='component_versions', through='itemstore.ComponentVersionContent', to='itemstore.Content'), + ), + migrations.AddField( + model_name='componentversion', name='learning_context_versions', - field=models.ManyToManyField(related_name='item_versions', through='itemstore.LearningContextVersionItemVersion', to='publishing.LearningContextVersion'), + field=models.ManyToManyField(related_name='component_versions', through='itemstore.LearningContextVersionComponentVersion', to='publishing.LearningContextVersion'), ), migrations.AddConstraint( model_name='learningcontextversionitemversion', - constraint=models.UniqueConstraint(fields=('learning_context_version_id', 'item_version_id'), name='lcviv_uniq_lcv_iv'), + constraint=models.UniqueConstraint(fields=('learning_context_version', 'item_version'), name='lcvsv_uniq_lcv_item_version'), ), migrations.AddConstraint( model_name='learningcontextversionitemversion', - constraint=models.UniqueConstraint(fields=('learning_context_version', 'item'), name='lcviv_uniq_lcv_item'), + constraint=models.UniqueConstraint(fields=('learning_context_version', 'item'), name='lcvsv_uniq_lcv_item'), ), - migrations.AddIndex( - model_name='itemversioncontent', - index=models.Index(fields=['content', 'item_version'], name='itemversioncontent_c_iv'), + migrations.AddConstraint( + model_name='learningcontextversioncomponentversion', + constraint=models.UniqueConstraint(fields=('learning_context_version', 'component_version'), name='lcviv_uniq_lcv_cv'), ), - migrations.AddIndex( - model_name='itemversioncontent', - index=models.Index(fields=['item_version', 'content'], name='itemversioncontent_iv_d'), + migrations.AddConstraint( + model_name='learningcontextversioncomponentversion', + constraint=models.UniqueConstraint(fields=('learning_context_version', 'component'), name='lcviv_uniq_lcv_component'), ), migrations.AddConstraint( - model_name='itemversioncontent', - constraint=models.UniqueConstraint(fields=('item_version', 'identifier'), name='itemversioncontent_uniq_iv_id'), + model_name='itemversioncomponentversion', + constraint=models.UniqueConstraint(fields=('item_version', 'order_num'), name='ivcv_uniq_item_component_order'), ), migrations.AddConstraint( model_name='item', - constraint=models.UniqueConstraint(fields=('learning_context', 'namespace', 'identifier'), name='item_uniq_lc_ns_identifier'), + constraint=models.UniqueConstraint(fields=('learning_context', 'identifier'), name='item_uniq_lc_identifier'), ), migrations.AddConstraint( model_name='content', constraint=models.UniqueConstraint(fields=('learning_context', 'type', 'sub_type', 'hash_digest'), name='content_uniq_lc_hd'), ), + migrations.AddIndex( + model_name='componentversioncontent', + index=models.Index(fields=['content', 'component_version'], name='componentversioncontent_c_cv'), + ), + migrations.AddIndex( + model_name='componentversioncontent', + index=models.Index(fields=['component_version', 'content'], name='componentversioncontent_cv_d'), + ), + migrations.AddConstraint( + model_name='componentversioncontent', + constraint=models.UniqueConstraint(fields=('component_version', 'identifier'), name='componentversioncontent_uniq_cv_id'), + ), + migrations.AddConstraint( + model_name='component', + constraint=models.UniqueConstraint(fields=('learning_context', 'namespace', 'identifier'), name='component_uniq_lc_ns_identifier'), + ), ] diff --git a/openedx_learning/core/itemstore/models.py b/openedx_learning/core/itemstore/models.py index 17066309..c2446ab9 100644 --- a/openedx_learning/core/itemstore/models.py +++ b/openedx_learning/core/itemstore/models.py @@ -1,10 +1,17 @@ """ -The item model hiearachy is: Item -> ItemVersion -> Content +The model hierarchy is Item -> Component -> Content. -Item is the versionless thing that is guaranteed to exist for the lifetime of -the LearningContext. An ItemVersion is a different version of that item for a -given LearningContext, and may include policy changes (like grading). Content -represents the raw byte data. +Content is a simple model holding unversioned, raw data, along with some simple +metadata like size and MIME type. + +Multiple pieces of Content may be associated with a Component. A Component is a +versioned thing that maps to a single Component Handler. This might be a Video, +a Problem, or some explanatatory HTML. + +An Item is a single, coherent piece of learning material from the student's +perspective. It has one or more Components, but these Components are tightly +coupled (e.g. a Video followed by a Problem that asks a question about the +Video). Items are also versioned. """ from django.db import models from django.core.validators import MaxValueValidator @@ -15,30 +22,136 @@ immutable_uuid_field, manual_date_time_field, ) -from ..publishing.models import LearningContext, LearningContextVersion +from ..publishing.models import ( + LearningContext, + LearningContextVersion, +) class Item(models.Model): + """ + A single piece of learning material from the student's perspective. + + An Item has an ordered list of one or more Components, but those Components + must be tightly coupled to each other. For instance, a Video Component + + a Problem Component that references the Video. + + Students may see the identifier of the Item in the address bar of their + browser. + """ + + uuid = immutable_uuid_field() + learning_context = models.ForeignKey(LearningContext, on_delete=models.CASCADE) + + # Mutable, app defined identifier. + identifier = identifier_field() + + created = manual_date_time_field() + modified = manual_date_time_field() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["learning_context", "identifier"], + name="item_uniq_lc_identifier", + ) + ] + + def __str__(self): + return f"{self.identifier}" + + +class ItemVersion(models.Model): + """ + A particular version of an Item. + + A new version should be created when there is a change to the set of + Components in a Item. + """ + + uuid = immutable_uuid_field() + item = models.ForeignKey(Item, on_delete=models.CASCADE) + title = models.CharField(max_length=1000, null=False, blank=True) + + component_versions = models.ManyToManyField( + "ComponentVersion", + through="ItemVersionComponentVersion", + related_name="item_versions", + ) + + learning_context_versions = models.ManyToManyField( + LearningContextVersion, + through="LearningContextVersionItemVersion", + related_name="item_versions", + ) + + +class ItemVersionComponentVersion(models.Model): + item_version = models.ForeignKey(ItemVersion, on_delete=models.CASCADE) + component_version = models.ForeignKey('ComponentVersion', on_delete=models.RESTRICT) + order_num = models.PositiveIntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["item_version", "order_num"], + name="ivcv_uniq_item_component_order", + ) + ] + + +class LearningContextVersionItemVersion(models.Model): + """ + Mapping of all ItemVersions for a given LearningContextVersion. + + This answers, "What version of these items is in this version of a course?" + There can be at most one version of a given Item for a given + LearningContextVersion. + """ + + learning_context_version = models.ForeignKey( + LearningContextVersion, on_delete=models.CASCADE + ) + item_version = models.ForeignKey(ItemVersion, on_delete=models.RESTRICT) + item = models.ForeignKey(Item, on_delete=models.RESTRICT) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["learning_context_version", "item_version"], + name="lcvsv_uniq_lcv_item_version", + ), + # For any given LearningContentVersion, there should be only one + # version of an Item. + models.UniqueConstraint( + fields=["learning_context_version", "item"], + name="lcvsv_uniq_lcv_item", + ), + ] + + +class Component(models.Model): """ This represents any content that has ever existed in a LearningContext. - An Item will have many ItemVersions over time, and most metadata is - associated with the ItemVersion model. Make a foreign key to this model when + A Component will have many ComponentVersions over time, and most metadata is + associated with the ComponentVersion model. Make a foreign key to this model when you need a stable reference that will exist for as long as the - LearningContext itself exists. It is possible for an Item to have no active - ItemVersion in the current LearningContextVersion (i.e. this content was at + LearningContext itself exists. It is possible for an Component to have no active + ComponentVersion in the current LearningContextVersion (i.e. this content was at some point removed from the "published" version). - An Item belongs to one and only one LearningContext. + An Component belongs to one and only one LearningContext. The UUID should be treated as immutable. The identifier field *is* mutable, - but changing it will affect all ItemVersions. + but changing it will affect all ComponentVersions. """ + uuid = immutable_uuid_field() learning_context = models.ForeignKey(LearningContext, on_delete=models.CASCADE) # namespace/identifier work together to give a more humanly readable, local - # identifier for an Item. + # identifier for an Component. namespace = models.CharField(max_length=100, null=False, blank=False) identifier = identifier_field() @@ -48,8 +161,12 @@ class Item(models.Model): class Meta: constraints = [ models.UniqueConstraint( - fields=["learning_context", "namespace", "identifier"], - name="item_uniq_lc_ns_identifier", + fields=[ + "learning_context", + "namespace", + "identifier", + ], + name="component_uniq_lc_ns_identifier", ) ] @@ -57,75 +174,80 @@ def __str__(self): return f"{self.identifier}" -class ItemVersion(models.Model): +class ComponentVersion(models.Model): """ - A particular version of an Item. + A particular version of an Component. - A new ItemVersion should be created anytime there is either a change to the + A new ComponentVersion should be created anytime there is either a change to the content or a change to the policy around a piece of content (e.g. schedule change). - - Each ItemVersion belongs to one and only one Item. + + Each ComponentVersion belongs to one and only one Component. TODO: created_by field? """ - uuid = immutable_uuid_field() - item = models.ForeignKey(Item, on_delete=models.CASCADE) - title = models.CharField(max_length=1000, blank=True, null=True) + uuid = immutable_uuid_field() + component = models.ForeignKey(Component, on_delete=models.CASCADE) created = manual_date_time_field() learning_context_versions = models.ManyToManyField( LearningContextVersion, - through='LearningContextVersionItemVersion', - related_name='item_versions', + through="LearningContextVersionComponentVersion", + related_name="component_versions", ) contents = models.ManyToManyField( - 'Content', - through='ItemVersionContent', - related_name='item_versions', + "Content", + through="ComponentVersionContent", + related_name="component_versions", ) def __str__(self): return f"{self.uuid}: {self.title}" -class LearningContextVersionItemVersion(models.Model): +class LearningContextVersionComponentVersion(models.Model): """ - Mapping of all ItemVersion in a given LearningContextVersion. + Mapping of all ComponentVersion in a given LearningContextVersion. """ - learning_context_version = models.ForeignKey(LearningContextVersion, on_delete=models.CASCADE) - item_version = models.ForeignKey(ItemVersion, on_delete=models.RESTRICT) - # item should always be derivable from item_version, but it exists in this - # model directly because MySQL doesn't support constraint conditions (see - # comments in the constraints section below for details). - item = models.ForeignKey(Item, on_delete=models.RESTRICT) + learning_context_version = models.ForeignKey( + LearningContextVersion, on_delete=models.CASCADE + ) + component_version = models.ForeignKey(ComponentVersion, on_delete=models.RESTRICT) + + # component should always be derivable from component_version, but it exists + # in this model directly because MySQL doesn't support constraint conditions + # (see comments in the constraints section below for details). + component = models.ForeignKey(Component, on_delete=models.RESTRICT) class Meta: constraints = [ - # The same ItemVersion should only show up once for a given + # The same ComponentVersion should only show up once for a given # LearningContextVersion. models.UniqueConstraint( - fields=["learning_context_version_id", "item_version_id"], - name="lcviv_uniq_lcv_iv", + fields=[ + "learning_context_version", + "component_version", + ], + name="lcviv_uniq_lcv_cv", ), - - # An Item should have at most one version of itself published as + # A Component should have at most one version of itself published as # part of any given LearningContextVersion. Having multiple - # ItemVersions from the same Item in a given LearningContextVersion - # would cause the identifiers to collide, which could cause buggy - # behavior without much benefit. - # + # ComponentVersions from the same Component in a given + # LearningContextVersion would cause the identifiers to collide, + # which could cause buggy behavior without much benefit. + # # Ideally, we could enforce this with a constraint condition that - # queried item_version.item, but MySQL does not support this. So we - # waste a little extra space to help enforce data integrity by - # adding a foreign key to the Item directly in this model, and then - # checking the uniqueness of (LearningContextVersion, Item). + # queried component_version.component, but MySQL does not support + # this. So we waste a little extra space to help enforce data + # integrity by adding a foreign key to the Component directly in + # this model, and then checking the uniqueness of + # (LearningContextVersion, Component). models.UniqueConstraint( - fields=["learning_context_version", "item"], - name="lcviv_uniq_lcv_item" - ) + fields=["learning_context_version", "component"], + name="lcviv_uniq_lcv_component", + ), ] @@ -146,7 +268,7 @@ class Content(models.Model): The other fields on Content are for data that is intrinsic to the file data itself (e.g. the size). Any smart parsing of the contents into more structured metadata should happen in other models that hang off of ItemInfo. - + Content models are not versioned in any way. The concept of versioning exists at a higher level. @@ -156,6 +278,7 @@ class Content(models.Model): things like PDF files and images, but the itention is for the vast majority of rows to be much smaller than that. """ + # Cap item size at 10 MB for now. MAX_SIZE = 10_000_000 @@ -177,6 +300,7 @@ class Content(models.Model): data = models.BinaryField(null=False, max_length=MAX_SIZE) + @property def mime_type(self): return f"{self.type}/{self.sub_type}" @@ -185,49 +309,55 @@ class Meta: # Make sure we don't store duplicates of this raw data within the # same LearningContext, unless they're of different mime types. models.UniqueConstraint( - fields=["learning_context", "type", "sub_type", "hash_digest"], + fields=[ + "learning_context", + "type", + "sub_type", + "hash_digest", + ], name="content_uniq_lc_hd", ) ] -class ItemVersionContent(models.Model): + +class ComponentVersionContent(models.Model): """ - Determines the Content for a given ItemVersion. + Determines the Content for a given ComponentVersion. - An ItemVersion may be associated with multiple pieces of binary data. For - instance, a Video version might be associated with multiple transcripts in - different languages. + An ComponentVersion may be associated with multiple pieces of binary data. + For instance, a Video ComponentVersion might be associated with multiple + transcripts in different languages. - When Content is associated with an ItemVersion, it has some local identifier - that is unique within the the context of that ItemVersion. This allows the - ItemVersion to do things like store an image file and reference it by a - "path" identifier. + When Content is associated with an ComponentVersion, it has some local + identifier that is unique within the the context of that ComponentVersion. + This allows the ComponentVersion to do things like store an image file and + reference it by a "path" identifier. - Content is immutable and sharable across multiple ItemVersions and even + Content is immutable and sharable across multiple ComponentVersions and even across LearningContexts. """ - item_version = models.ForeignKey(ItemVersion, on_delete=models.CASCADE) + + component_version = models.ForeignKey(ComponentVersion, on_delete=models.CASCADE) content = models.ForeignKey(Content, on_delete=models.RESTRICT) identifier = identifier_field() class Meta: constraints = [ - # Uniqueness is only by ItemVersion and identifier. If for some - # reason an ItemVersion wants to associate the same piece of content + # Uniqueness is only by ComponentVersion and identifier. If for some + # reason an ComponentVersion wants to associate the same piece of content # with two different identifiers, that is permitted. models.UniqueConstraint( - fields=["item_version", "identifier"], - name="itemversioncontent_uniq_iv_id", + fields=["component_version", "identifier"], + name="componentversioncontent_uniq_cv_id", ), ] indexes = [ models.Index( - fields=['content', 'item_version'], - name="itemversioncontent_c_iv", + fields=["content", "component_version"], + name="componentversioncontent_c_cv", ), models.Index( - fields=['item_version', 'content'], - name="itemversioncontent_iv_d", + fields=["component_version", "content"], + name="componentversioncontent_cv_d", ), ] - diff --git a/openedx_learning/core/itemstore/models_api.py b/openedx_learning/core/itemstore/models_api.py index 83bfe006..4d84e994 100644 --- a/openedx_learning/core/itemstore/models_api.py +++ b/openedx_learning/core/itemstore/models_api.py @@ -1,18 +1,18 @@ from django.db import models -from .models import ItemVersion +from .models import ComponentVersion -class ItemVersionDataMixin(models.Model): +class ComponentVersionDataMixin(models.Model): """ - Minimal abstract model to let people attach data to ItemVersions. + Minimal abstract model to let people attach data to ComponentVersions. The idea is that if you have a model that is associated with a specific - version of an item, the join is going to be 1:1 with an ItemVersion, and + version of an item, the join is going to be 1:1 with an ComponentVersion, and potentially M:1 with your data model. """ - item_version = models.OneToOneField( - ItemVersion, + component_version = models.OneToOneField( + ComponentVersion, on_delete=models.CASCADE, primary_key=True, ) From 37b6327865cae3d244082441363016800d0113e7 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Tue, 23 Aug 2022 15:56:41 -0400 Subject: [PATCH 02/33] create all the models and some admin views of them --- .../management/commands/load_course_data.py | 17 ++++--- .../staticassets/migrations/0001_initial.py | 2 +- .../contrib/staticassets/models.py | 2 + openedx_learning/core/itemstore/admin.py | 12 +++++ .../core/itemstore/management/__init__.py | 0 .../itemstore/management/commands/__init__.py | 0 .../commands/load_itemstore_sample_data.py | 48 +++++++++++++++++++ .../core/itemstore/migrations/0001_initial.py | 13 +++-- openedx_learning/core/itemstore/models.py | 16 +++++-- openedx_learning/core/publishing/admin.py | 24 ++++++++-- .../migrations/0002_drop_prev_version.py | 28 +++++++++++ openedx_learning/core/publishing/models.py | 3 +- openedx_learning/lib/fields.py | 1 + requirements/base.in | 4 ++ requirements/base.txt | 4 +- requirements/ci.txt | 4 +- requirements/dev.txt | 12 ++--- requirements/doc.txt | 4 +- requirements/quality.txt | 8 ++-- requirements/test.txt | 8 ++-- 20 files changed, 170 insertions(+), 40 deletions(-) create mode 100644 openedx_learning/core/itemstore/admin.py create mode 100644 openedx_learning/core/itemstore/management/__init__.py create mode 100644 openedx_learning/core/itemstore/management/commands/__init__.py create mode 100644 openedx_learning/core/itemstore/management/commands/load_itemstore_sample_data.py create mode 100644 openedx_learning/core/publishing/migrations/0002_drop_prev_version.py diff --git a/olx_importer/management/commands/load_course_data.py b/olx_importer/management/commands/load_course_data.py index e814b806..79e54221 100644 --- a/olx_importer/management/commands/load_course_data.py +++ b/olx_importer/management/commands/load_course_data.py @@ -92,11 +92,13 @@ def load_course_data(self, learning_context_identifier, course_data_path): for item_raw_id, type, sub_type, hash_digest in existing_item_raws } - static_asset_paths_to_atom_ids = self.import_static_assets( - course_data_path, - item_raw_id_cache, - now, - ) + static_asset_paths_to_atom_ids = {} + +# static_asset_paths_to_atom_ids = self.import_static_assets( +# course_data_path, +# item_raw_id_cache, +# now, +# ) for block_type in SUPPORTED_TYPES: self.import_block_type( @@ -188,7 +190,8 @@ def import_block_type(self, block_type, content_path, static_asset_paths_to_atom component, _created = Component.objects.get_or_create( learning_context=self.learning_context, namespace='xblock.v1', - identifier=f"{block_type}+{identifier}", + type=block_type, + identifier=identifier, defaults = { 'created': now, 'modified': now, @@ -202,7 +205,7 @@ def import_block_type(self, block_type, content_path, static_asset_paths_to_atom content, _created = Content.objects.get_or_create( learning_context=self.learning_context, type='application', - sub_type='vnd.openedx.xblock.v1.{block_type}+xml', + sub_type=f'vnd.openedx.xblock.v1.{block_type}+xml', hash_digest=hash_digest, defaults = dict( data=data_bytes, diff --git a/openedx_learning/contrib/staticassets/migrations/0001_initial.py b/openedx_learning/contrib/staticassets/migrations/0001_initial.py index 09947f78..239854b2 100644 --- a/openedx_learning/contrib/staticassets/migrations/0001_initial.py +++ b/openedx_learning/contrib/staticassets/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-08-12 14:46 +# Generated by Django 3.2.10 on 2022-08-14 01:44 from django.db import migrations, models import django.db.models.deletion diff --git a/openedx_learning/contrib/staticassets/models.py b/openedx_learning/contrib/staticassets/models.py index d41e7c69..f0803acb 100644 --- a/openedx_learning/contrib/staticassets/models.py +++ b/openedx_learning/contrib/staticassets/models.py @@ -13,6 +13,8 @@ from openedx_learning.core.itemstore.models_api import ComponentVersionDataMixin +# The following is a placeholder, but there's not point in making models + class Asset(models.Model): """ An Asset may be more than just a single file. diff --git a/openedx_learning/core/itemstore/admin.py b/openedx_learning/core/itemstore/admin.py new file mode 100644 index 00000000..d8eccc2e --- /dev/null +++ b/openedx_learning/core/itemstore/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + +from .models import ( + Item, ItemVersion, ItemVersionComponentVersion, + Component, ComponentVersion, ComponentVersionContent, +) + +@admin.register(Item) +class ItemAdmin(admin.ModelAdmin): + list_display = ('identifier', 'uuid', 'created', 'modified') + readonly_fields = ['uuid'] + diff --git a/openedx_learning/core/itemstore/management/__init__.py b/openedx_learning/core/itemstore/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/core/itemstore/management/commands/__init__.py b/openedx_learning/core/itemstore/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/core/itemstore/management/commands/load_itemstore_sample_data.py b/openedx_learning/core/itemstore/management/commands/load_itemstore_sample_data.py new file mode 100644 index 00000000..932e513f --- /dev/null +++ b/openedx_learning/core/itemstore/management/commands/load_itemstore_sample_data.py @@ -0,0 +1,48 @@ +""" +Seed some sample data. +""" +from datetime import datetime, timezone +import logging + +from django.core.management.base import BaseCommand +from django.db import transaction + +import yaml + +from openedx_learning.contrib.staticassets.models import Asset, ComponentVersionAsset +from openedx_learning.core.publishing.models import ( + LearningContext, LearningContextVersion +) +from openedx_learning.core.itemstore.models import ( + Content, Component, ComponentVersion, LearningContextVersionComponentVersion +) +from openedx_learning.lib.fields import create_hash_digest + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Load sample data' + + def add_arguments(self, parser): + parser.add_argument('learning_context_identifier', type=str) + parser.add_argument('itemstore_yaml_file', type=open) + + def handle(self, learning_context_identifier, itemstore_yaml_file, **options): + self.learning_context_identifier = learning_context_identifier + now = datetime.now(timezone.utc) + lc = LearningContext.objects.get_or_create( + identifier=learning_context_identifier, + defaults={'created': now}, + ) + load_itemstore_data(itemstore_yaml_file, lc) + + +def load_itemstore_data(itemstore_yaml_file, learning_context): + data = yaml.safe_load(itemstore_yaml_file) + for identifier, item_data in data['items'].items(): + pass + + + print(data) + diff --git a/openedx_learning/core/itemstore/migrations/0001_initial.py b/openedx_learning/core/itemstore/migrations/0001_initial.py index adfdd2ae..eb06bc0e 100644 --- a/openedx_learning/core/itemstore/migrations/0001_initial.py +++ b/openedx_learning/core/itemstore/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-08-12 14:46 +# Generated by Django 3.2.10 on 2022-08-14 01:44 import django.core.validators from django.db import migrations, models @@ -21,6 +21,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('namespace', models.CharField(max_length=100)), + ('type', models.CharField(blank=True, max_length=100)), ('identifier', models.CharField(max_length=255)), ('created', models.DateTimeField()), ('modified', models.DateTimeField()), @@ -53,7 +54,6 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('title', models.CharField(blank=True, max_length=1000)), - ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itemstore.item')), ], ), migrations.CreateModel( @@ -85,9 +85,14 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='itemversion', - name='item_components', + name='component_versions', field=models.ManyToManyField(related_name='item_versions', through='itemstore.ItemVersionComponentVersion', to='itemstore.ComponentVersion'), ), + migrations.AddField( + model_name='itemversion', + name='item', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itemstore.item'), + ), migrations.AddField( model_name='itemversion', name='learning_context_versions', @@ -167,6 +172,6 @@ class Migration(migrations.Migration): ), migrations.AddConstraint( model_name='component', - constraint=models.UniqueConstraint(fields=('learning_context', 'namespace', 'identifier'), name='component_uniq_lc_ns_identifier'), + constraint=models.UniqueConstraint(fields=('learning_context', 'namespace', 'type', 'identifier'), name='component_uniq_lc_ns_type_identifier'), ), ] diff --git a/openedx_learning/core/itemstore/models.py b/openedx_learning/core/itemstore/models.py index c2446ab9..3b332d15 100644 --- a/openedx_learning/core/itemstore/models.py +++ b/openedx_learning/core/itemstore/models.py @@ -87,6 +87,9 @@ class ItemVersion(models.Model): class ItemVersionComponentVersion(models.Model): + """ + TODO: Should this have optional title? + """ item_version = models.ForeignKey(ItemVersion, on_delete=models.CASCADE) component_version = models.ForeignKey('ComponentVersion', on_delete=models.RESTRICT) order_num = models.PositiveIntegerField() @@ -150,9 +153,15 @@ class Component(models.Model): uuid = immutable_uuid_field() learning_context = models.ForeignKey(LearningContext, on_delete=models.CASCADE) - # namespace/identifier work together to give a more humanly readable, local - # identifier for an Component. + # namespace and type work together to help figure out what Component needs + # to handle this data. A namespace is *required*. namespace = models.CharField(max_length=100, null=False, blank=False) + + # type is a way to help sub-divide namespace if that's convenient. This + # field cannot be null, but it can be blank if it's not necessary. + type = models.CharField(max_length=100, null=False, blank=True) + + # identifier is local to a learning_context + namespace + type. identifier = identifier_field() created = manual_date_time_field() @@ -164,9 +173,10 @@ class Meta: fields=[ "learning_context", "namespace", + "type", "identifier", ], - name="component_uniq_lc_ns_identifier", + name="component_uniq_lc_ns_type_identifier", ) ] diff --git a/openedx_learning/core/publishing/admin.py b/openedx_learning/core/publishing/admin.py index 23867af1..e588d965 100644 --- a/openedx_learning/core/publishing/admin.py +++ b/openedx_learning/core/publishing/admin.py @@ -1,3 +1,4 @@ +import re from django.contrib import admin from .models import ( @@ -5,12 +6,25 @@ LearningContextVersion, ) -# @admin.register(LearningContext) -# class LearningContextAdmin(admin.ModelAdmin): -# pass +class LearningContextVersionInline(admin.TabularInline): + model = LearningContextVersion + fk_name = 'learning_context' + readonly_fields = ('created', 'uuid') + min_num = 1 -admin.site.register(LearningContext) -admin.site.register(LearningContextVersion) + +@admin.register(LearningContext) +class LearningContextAdmin(admin.ModelAdmin): + fields = ('identifier', 'uuid', 'created') + readonly_fields = ('uuid', 'created') + list_display = ('identifier', 'uuid', 'created') + + def get_inlines(self, request, obj): + if obj: + return [LearningContextVersionInline] + return [] + +# admin.site.register(LearningContextVersion) """ admin.site.register(LearningContextBranch) diff --git a/openedx_learning/core/publishing/migrations/0002_drop_prev_version.py b/openedx_learning/core/publishing/migrations/0002_drop_prev_version.py new file mode 100644 index 00000000..ce5f2c2b --- /dev/null +++ b/openedx_learning/core/publishing/migrations/0002_drop_prev_version.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.10 on 2022-08-14 04:35 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('publishing', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='learningcontextversion', + name='prev_version', + ), + migrations.AlterField( + model_name='learningcontext', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID'), + ), + migrations.AlterField( + model_name='learningcontextversion', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID'), + ), + ] diff --git a/openedx_learning/core/publishing/models.py b/openedx_learning/core/publishing/models.py index 19a4b643..396b9f05 100644 --- a/openedx_learning/core/publishing/models.py +++ b/openedx_learning/core/publishing/models.py @@ -24,7 +24,7 @@ class LearningContext(models.Model): created = manual_date_time_field() def __str__(self): - return f"LearningContext {self.uuid}: {self.identifier}" + return f"{self.identifier} ({self.uuid})" class Meta: constraints = [ @@ -40,7 +40,6 @@ class LearningContextVersion(models.Model): """ uuid = immutable_uuid_field() learning_context = models.ForeignKey(LearningContext, on_delete=models.CASCADE) - prev_version = models.ForeignKey(LearningContext, on_delete=models.RESTRICT, null=True, related_name='+') created = manual_date_time_field() diff --git a/openedx_learning/lib/fields.py b/openedx_learning/lib/fields.py index 6ff8c0d9..175d918d 100644 --- a/openedx_learning/lib/fields.py +++ b/openedx_learning/lib/fields.py @@ -54,6 +54,7 @@ def immutable_uuid_field(): null=False, editable=False, unique=True, + verbose_name="UUID", # Just makes the Django admin output properly capitalized ) def hash_field(): diff --git a/requirements/base.in b/requirements/base.in index 6a909522..b5b1874c 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -3,4 +3,8 @@ Django<4.0 # Web application framework +# For the Python API layer (eventually) attrs + +# Serialization +pyyaml diff --git a/requirements/base.txt b/requirements/base.txt index 40f87f78..5fe58a17 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -10,7 +10,9 @@ attrs==22.1.0 # via -r requirements/base.in django==3.2.15 # via -r requirements/base.in -pytz==2022.1 +pytz==2022.2.1 # via django +pyyaml==6.0 + # via -r requirements/base.in sqlparse==0.4.2 # via django diff --git a/requirements/ci.txt b/requirements/ci.txt index bd0b6ffa..37ea3386 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -16,11 +16,11 @@ coverage==6.4.3 # via codecov distlib==0.3.5 # via virtualenv -filelock==3.7.1 +filelock==3.8.0 # via # tox # virtualenv -grimp==1.2.3 +grimp==1.3 # via import-linter idna==3.3 # via requests diff --git a/requirements/dev.txt b/requirements/dev.txt index 33087f21..41115180 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -89,12 +89,12 @@ edx-i18n-tools==0.9.1 # via -r requirements/dev.in edx-lint==5.2.4 # via -r requirements/quality.txt -filelock==3.7.1 +filelock==3.8.0 # via # -r requirements/ci.txt # tox # virtualenv -grimp==1.2.3 +grimp==1.3 # via # -r requirements/ci.txt # import-linter @@ -123,7 +123,7 @@ jinja2==3.1.2 # -r requirements/quality.txt # code-annotations # diff-cover -keyring==23.8.1 +keyring==23.8.2 # via # -r requirements/quality.txt # twine @@ -153,7 +153,7 @@ packaging==21.3 # tox path==16.4.0 # via edx-i18n-tools -pbr==5.9.0 +pbr==5.10.0 # via # -r requirements/quality.txt # stevedore @@ -237,7 +237,7 @@ python-slugify==6.1.2 # via # -r requirements/quality.txt # code-annotations -pytz==2022.1 +pytz==2022.2.1 # via # -r requirements/quality.txt # django @@ -305,7 +305,7 @@ tomli==2.0.1 # pep517 # pylint # pytest -tomlkit==0.11.1 +tomlkit==0.11.4 # via # -r requirements/quality.txt # pylint diff --git a/requirements/doc.txt b/requirements/doc.txt index f2f1d75c..e8f2f86d 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -68,7 +68,7 @@ packaging==21.3 # -r requirements/test.txt # pytest # sphinx -pbr==5.9.0 +pbr==5.10.0 # via # -r requirements/test.txt # stevedore @@ -102,7 +102,7 @@ python-slugify==6.1.2 # via # -r requirements/test.txt # code-annotations -pytz==2022.1 +pytz==2022.2.1 # via # -r requirements/test.txt # babel diff --git a/requirements/quality.txt b/requirements/quality.txt index 5b541835..7fbe7d88 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -66,7 +66,7 @@ jinja2==3.1.2 # via # -r requirements/test.txt # code-annotations -keyring==23.8.1 +keyring==23.8.2 # via twine lazy-object-proxy==1.7.1 # via astroid @@ -80,7 +80,7 @@ packaging==21.3 # via # -r requirements/test.txt # pytest -pbr==5.9.0 +pbr==5.10.0 # via # -r requirements/test.txt # stevedore @@ -135,7 +135,7 @@ python-slugify==6.1.2 # via # -r requirements/test.txt # code-annotations -pytz==2022.1 +pytz==2022.2.1 # via # -r requirements/test.txt # django @@ -179,7 +179,7 @@ tomli==2.0.1 # coverage # pylint # pytest -tomlkit==0.11.1 +tomlkit==0.11.4 # via pylint twine==4.0.1 # via -r requirements/quality.in diff --git a/requirements/test.txt b/requirements/test.txt index 3146dfa4..d04b653b 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -27,7 +27,7 @@ markupsafe==2.1.1 # via jinja2 packaging==21.3 # via pytest -pbr==5.9.0 +pbr==5.10.0 # via stevedore pluggy==1.0.0 # via pytest @@ -45,12 +45,14 @@ pytest-django==4.5.2 # via -r requirements/test.in python-slugify==6.1.2 # via code-annotations -pytz==2022.1 +pytz==2022.2.1 # via # -r requirements/base.txt # django pyyaml==6.0 - # via code-annotations + # via + # -r requirements/base.txt + # code-annotations sqlparse==0.4.2 # via # -r requirements/base.txt From fd4c1184c7ba6e1dc864fa746eb990a1937f9a96 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Sat, 3 Sep 2022 12:30:16 -0400 Subject: [PATCH 03/33] stubbing out a LOR app --- data/itemstore/sample_data.yaml | 61 +++++++++++++++++++ openedx_lor/__init__.py | 0 openedx_lor/authoring/__init__.py | 0 openedx_lor/authoring/apps.py | 7 +++ .../authoring/templates/authoring/base.html | 9 +++ .../templates/authoring/item/list.html | 15 +++++ openedx_lor/authoring/urls.py | 7 +++ openedx_lor/authoring/views.py | 14 +++++ openedx_lor/urls.py | 5 ++ projects/dev.py | 2 + projects/urls.py | 1 + requirements/base.in | 2 +- requirements/base.txt | 6 +- requirements/ci.txt | 12 ++-- requirements/dev.txt | 26 ++++---- requirements/doc.txt | 21 ++++--- requirements/quality.txt | 20 +++--- requirements/test.txt | 10 +-- 18 files changed, 172 insertions(+), 46 deletions(-) create mode 100644 data/itemstore/sample_data.yaml create mode 100644 openedx_lor/__init__.py create mode 100644 openedx_lor/authoring/__init__.py create mode 100644 openedx_lor/authoring/apps.py create mode 100644 openedx_lor/authoring/templates/authoring/base.html create mode 100644 openedx_lor/authoring/templates/authoring/item/list.html create mode 100644 openedx_lor/authoring/urls.py create mode 100644 openedx_lor/authoring/views.py create mode 100644 openedx_lor/urls.py diff --git a/data/itemstore/sample_data.yaml b/data/itemstore/sample_data.yaml new file mode 100644 index 00000000..73f58cb6 --- /dev/null +++ b/data/itemstore/sample_data.yaml @@ -0,0 +1,61 @@ +# This is a quick hack to be able to quickly load different data scenarios into +# the data model for testing purposes, and is NOT intended to be a supported +# serialization format for interchange purposes. +# +# +# items is a dictionary with Item identifiers for keys. This represents the +# the entirety of the itemstore contents for a single Learning Context. There is +# no natural ordering of Items within an Itemstore +items: + what_is_modulestore: + title: "What is ModuleStore?" + + # Components are ordered within an Item, so we represent them as an array + components: + # These Component "id" entries get parsed to {namespace}:{type}{+identifier} + # The type can be omitted, in which case it's an empty string in the database + - id: xblock.v1:markdown+modulestore_introduction + + # Content is the raw data. A Component has at least one piece of Content + # associated with it, with an identifier that is unique within the + # Component. + content: + # "markdown" is the identifier for this piece of Content in this component + markdown: + type: text/markdown + + # TODO: make a "file:" equivalent to this that can reference a file + data: > + The ModuleStore is the *original storage and runtime* for XModules + and XBlocks. It is not as simple as we would like it to be. + + This is really just a test of mutliple lines of text. + - id: xblock.v1:problem+modulestore_introduction: + content: + markdown: + type: vnd.openedx.xblock.v1.problem+markdown + name: markdown + data: > + Pretend this is a longer intro. + + >>The ModuleStore is:<< + + (x) a storage mechanism for XBlocks and XModules + (x) a runtime for XBlocks and XModules + (x) a giant headache + ( ) a wee, harmless little bunny + olx: + type: vnd.openedx.xblock.v1.problem+xml + data: > + + +

Pretend this is a longer intro.

+ + + a storage mechanism for XBlocks and XModules + a runtime for XBlocks and XModules + a giant headache + a wee, harmless little bunny + +
+
diff --git a/openedx_lor/__init__.py b/openedx_lor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_lor/authoring/__init__.py b/openedx_lor/authoring/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_lor/authoring/apps.py b/openedx_lor/authoring/apps.py new file mode 100644 index 00000000..c7f37aef --- /dev/null +++ b/openedx_lor/authoring/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AuthoringConfig(AppConfig): + name = "openedx_lor.authoring" + verbose_name = "Open edX LOR: Authoring" + default_auto_field = 'django.db.models.BigAutoField' diff --git a/openedx_lor/authoring/templates/authoring/base.html b/openedx_lor/authoring/templates/authoring/base.html new file mode 100644 index 00000000..55c85f58 --- /dev/null +++ b/openedx_lor/authoring/templates/authoring/base.html @@ -0,0 +1,9 @@ + + + + {% block title %}Authoring{% endblock %} + + + {% block content %}{% endblock%} + + \ No newline at end of file diff --git a/openedx_lor/authoring/templates/authoring/item/list.html b/openedx_lor/authoring/templates/authoring/item/list.html new file mode 100644 index 00000000..5b8f9952 --- /dev/null +++ b/openedx_lor/authoring/templates/authoring/item/list.html @@ -0,0 +1,15 @@ +{% extends "../base.html" %} + +{% block title %}Items{% endblock %} + +{% block content %} + + +{% for item in items %} + + + +{% endfor %} +
{{ item.title }}
+ +{% endblock %} diff --git a/openedx_lor/authoring/urls.py b/openedx_lor/authoring/urls.py new file mode 100644 index 00000000..c4f23484 --- /dev/null +++ b/openedx_lor/authoring/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('item/', views.item_list, name='item_list'), +] diff --git a/openedx_lor/authoring/views.py b/openedx_lor/authoring/views.py new file mode 100644 index 00000000..5297dc62 --- /dev/null +++ b/openedx_lor/authoring/views.py @@ -0,0 +1,14 @@ +from pipes import Template +from django.template.response import TemplateResponse + +from openedx_learning.core.itemstore.models import Item + +def item_list(request): + items = Item.objects.all() + context = { + 'items': items, + } + return TemplateResponse(request, 'authoring/item/list.html', context) + +def item_create(request): + pass diff --git a/openedx_lor/urls.py b/openedx_lor/urls.py new file mode 100644 index 00000000..ef11e1b5 --- /dev/null +++ b/openedx_lor/urls.py @@ -0,0 +1,5 @@ +from django.urls import include, path + +urlpatterns = [ + path('authoring/', include('openedx_lor.authoring.urls')), +] diff --git a/projects/dev.py b/projects/dev.py index df59466f..b3905a39 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -42,6 +42,8 @@ # Apps that don't belong in this repo in the long term, but are here to make # testing/iteration easier until the APIs stabilize. 'olx_importer.apps.OLXImporterConfig', + + 'openedx_lor.authoring.apps.AuthoringConfig', ) MIDDLEWARE = [ diff --git a/projects/urls.py b/projects/urls.py index 0cebc3f7..277a206a 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -4,4 +4,5 @@ urlpatterns = [ path('admin/doc/', include('django.contrib.admindocs.urls')), path('admin/', admin.site.urls), + path('', include('openedx_lor.urls')) ] diff --git a/requirements/base.in b/requirements/base.in index b5b1874c..c07753df 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -1,7 +1,7 @@ # Core requirements for using this application -c constraints.txt -Django<4.0 # Web application framework +Django # Web application framework # For the Python API layer (eventually) attrs diff --git a/requirements/base.txt b/requirements/base.txt index 5fe58a17..8b663f92 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,10 +8,10 @@ asgiref==3.5.2 # via django attrs==22.1.0 # via -r requirements/base.in -django==3.2.15 - # via -r requirements/base.in -pytz==2022.2.1 +backports-zoneinfo==0.2.1 # via django +django==4.1 + # via -r requirements/base.in pyyaml==6.0 # via -r requirements/base.in sqlparse==0.4.2 diff --git a/requirements/ci.txt b/requirements/ci.txt index 37ea3386..c5aba6ff 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -6,13 +6,13 @@ # certifi==2022.6.15 # via requests -charset-normalizer==2.1.0 +charset-normalizer==2.1.1 # via requests click==8.1.3 # via import-linter codecov==2.1.12 # via -r requirements/ci.in -coverage==6.4.3 +coverage==6.4.4 # via codecov distlib==0.3.5 # via virtualenv @@ -24,9 +24,9 @@ grimp==1.3 # via import-linter idna==3.3 # via requests -import-linter==1.2.7 +import-linter==1.3.0 # via -r requirements/ci.in -networkx==2.8.5 +networkx==2.8.6 # via grimp packaging==21.3 # via tox @@ -46,7 +46,9 @@ toml==0.10.2 # via tox tox==3.25.1 # via -r requirements/ci.in -urllib3==1.26.11 +typing-extensions==4.3.0 + # via import-linter +urllib3==1.26.12 # via requests virtualenv==20.16.3 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index 41115180..a56e0de6 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -17,6 +17,10 @@ attrs==22.1.0 # via # -r requirements/quality.txt # pytest +backports-zoneinfo==0.2.1 + # via + # -r requirements/quality.txt + # django bleach==5.0.1 # via # -r requirements/quality.txt @@ -32,7 +36,7 @@ certifi==2022.6.15 # requests chardet==5.0.0 # via diff-cover -charset-normalizer==2.1.0 +charset-normalizer==2.1.1 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -61,7 +65,7 @@ commonmark==0.9.1 # via # -r requirements/quality.txt # rich -coverage[toml]==6.4.3 +coverage[toml]==6.4.4 # via # -r requirements/ci.txt # -r requirements/quality.txt @@ -77,7 +81,7 @@ distlib==0.3.5 # via # -r requirements/ci.txt # virtualenv -django==3.2.15 +django==4.1 # via # -r requirements/quality.txt # edx-i18n-tools @@ -103,7 +107,7 @@ idna==3.3 # -r requirements/ci.txt # -r requirements/quality.txt # requests -import-linter==1.2.7 +import-linter==1.3.0 # via -r requirements/ci.txt importlib-metadata==4.12.0 # via @@ -139,7 +143,7 @@ mccabe==0.7.0 # via # -r requirements/quality.txt # pylint -networkx==2.8.5 +networkx==2.8.6 # via # -r requirements/ci.txt # grimp @@ -192,7 +196,7 @@ pycodestyle==2.9.1 # via -r requirements/quality.txt pydocstyle==6.1.1 # via -r requirements/quality.txt -pygments==2.12.0 +pygments==2.13.0 # via # -r requirements/quality.txt # diff-cover @@ -237,16 +241,12 @@ python-slugify==6.1.2 # via # -r requirements/quality.txt # code-annotations -pytz==2022.2.1 - # via - # -r requirements/quality.txt - # django pyyaml==6.0 # via # -r requirements/quality.txt # code-annotations # edx-i18n-tools -readme-renderer==36.0 +readme-renderer==37.0 # via # -r requirements/quality.txt # twine @@ -319,11 +319,13 @@ twine==4.0.1 # via -r requirements/quality.txt typing-extensions==4.3.0 # via + # -r requirements/ci.txt # -r requirements/quality.txt # astroid + # import-linter # pylint # rich -urllib3==1.26.11 +urllib3==1.26.12 # via # -r requirements/ci.txt # -r requirements/quality.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index e8f2f86d..5eb7c602 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -16,11 +16,15 @@ attrs==22.1.0 # pytest babel==2.10.3 # via sphinx +backports-zoneinfo==0.2.1 + # via + # -r requirements/test.txt + # django bleach==5.0.1 # via readme-renderer certifi==2022.6.15 # via requests -charset-normalizer==2.1.0 +charset-normalizer==2.1.1 # via requests click==8.1.3 # via @@ -28,11 +32,11 @@ click==8.1.3 # code-annotations code-annotations==1.3.0 # via -r requirements/test.txt -coverage[toml]==6.4.3 +coverage[toml]==6.4.4 # via # -r requirements/test.txt # pytest-cov -django==3.2.15 +django==4.1 # via -r requirements/test.txt doc8==1.0.0 # via -r requirements/doc.in @@ -80,7 +84,7 @@ py==1.11.0 # via # -r requirements/test.txt # pytest -pygments==2.12.0 +pygments==2.13.0 # via # doc8 # readme-renderer @@ -103,15 +107,12 @@ python-slugify==6.1.2 # -r requirements/test.txt # code-annotations pytz==2022.2.1 - # via - # -r requirements/test.txt - # babel - # django + # via babel pyyaml==6.0 # via # -r requirements/test.txt # code-annotations -readme-renderer==36.0 +readme-renderer==37.0 # via -r requirements/doc.in requests==2.28.1 # via sphinx @@ -160,7 +161,7 @@ tomli==2.0.1 # coverage # doc8 # pytest -urllib3==1.26.11 +urllib3==1.26.12 # via requests webencodings==0.5.1 # via bleach diff --git a/requirements/quality.txt b/requirements/quality.txt index 7fbe7d88..fcf8db39 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -16,11 +16,15 @@ attrs==22.1.0 # via # -r requirements/test.txt # pytest +backports-zoneinfo==0.2.1 + # via + # -r requirements/test.txt + # django bleach==5.0.1 # via readme-renderer certifi==2022.6.15 # via requests -charset-normalizer==2.1.0 +charset-normalizer==2.1.1 # via requests click==8.1.3 # via @@ -36,13 +40,13 @@ code-annotations==1.3.0 # edx-lint commonmark==0.9.1 # via rich -coverage[toml]==6.4.3 +coverage[toml]==6.4.4 # via # -r requirements/test.txt # pytest-cov dill==0.3.5.1 # via pylint -django==3.2.15 +django==4.1 # via -r requirements/test.txt docutils==0.19 # via readme-renderer @@ -100,7 +104,7 @@ pycodestyle==2.9.1 # via -r requirements/quality.in pydocstyle==6.1.1 # via -r requirements/quality.in -pygments==2.12.0 +pygments==2.13.0 # via # readme-renderer # rich @@ -135,15 +139,11 @@ python-slugify==6.1.2 # via # -r requirements/test.txt # code-annotations -pytz==2022.2.1 - # via - # -r requirements/test.txt - # django pyyaml==6.0 # via # -r requirements/test.txt # code-annotations -readme-renderer==36.0 +readme-renderer==37.0 # via twine requests==2.28.1 # via @@ -188,7 +188,7 @@ typing-extensions==4.3.0 # astroid # pylint # rich -urllib3==1.26.11 +urllib3==1.26.12 # via # requests # twine diff --git a/requirements/test.txt b/requirements/test.txt index d04b653b..1cf26046 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -12,11 +12,15 @@ attrs==22.1.0 # via # -r requirements/base.txt # pytest +backports-zoneinfo==0.2.1 + # via + # -r requirements/base.txt + # django click==8.1.3 # via code-annotations code-annotations==1.3.0 # via -r requirements/test.in -coverage[toml]==6.4.3 +coverage[toml]==6.4.4 # via pytest-cov # via -r requirements/base.txt iniconfig==1.1.1 @@ -45,10 +49,6 @@ pytest-django==4.5.2 # via -r requirements/test.in python-slugify==6.1.2 # via code-annotations -pytz==2022.2.1 - # via - # -r requirements/base.txt - # django pyyaml==6.0 # via # -r requirements/base.txt From 9a7351f4ecc38d38bc18720cf36fb8446b2f334c Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 5 Sep 2022 17:45:07 -0400 Subject: [PATCH 04/33] before massive refactor of REST API into openedx_learning --- .../staticassets/migrations/0001_initial.py | 32 ----------------- .../core/itemstore/migrations/0001_initial.py | 18 +++++----- openedx_learning/core/publishing/api.py | 14 ++++---- .../publishing/migrations/0001_initial.py | 9 ++--- .../migrations/0002_drop_prev_version.py | 28 --------------- openedx_learning/core/publishing/models.py | 3 ++ .../authoring/migrations/0001_initial.py | 22 ++++++++++++ .../authoring}/migrations/__init__.py | 0 openedx_lor/authoring/models.py | 12 +++++++ openedx_lor/authoring/rest_api/__init__.py | 0 openedx_lor/authoring/rest_api/v1/__init__.py | 0 .../authoring/rest_api/v1/components.py | 26 ++++++++++++++ openedx_lor/authoring/rest_api/v1/items.py | 26 ++++++++++++++ .../authoring/rest_api/v1/libraries.py | 27 +++++++++++++++ openedx_lor/authoring/rest_api/v1/urls.py | 9 +++++ openedx_lor/authoring/urls.py | 7 ---- openedx_lor/authoring/views.py | 14 -------- openedx_lor/urls.py | 2 +- projects/dev.py | 4 ++- requirements/base.in | 5 +++ requirements/base.txt | 15 ++++++++ requirements/ci.txt | 4 +-- requirements/dev.txt | 33 ++++++++++++++---- requirements/doc.txt | 25 +++++++++++--- requirements/quality.txt | 34 ++++++++++++++----- requirements/test.txt | 23 ++++++++++++- 26 files changed, 267 insertions(+), 125 deletions(-) delete mode 100644 openedx_learning/contrib/staticassets/migrations/0001_initial.py delete mode 100644 openedx_learning/core/publishing/migrations/0002_drop_prev_version.py create mode 100644 openedx_lor/authoring/migrations/0001_initial.py rename {openedx_learning/contrib/staticassets => openedx_lor/authoring}/migrations/__init__.py (100%) create mode 100644 openedx_lor/authoring/models.py create mode 100644 openedx_lor/authoring/rest_api/__init__.py create mode 100644 openedx_lor/authoring/rest_api/v1/__init__.py create mode 100644 openedx_lor/authoring/rest_api/v1/components.py create mode 100644 openedx_lor/authoring/rest_api/v1/items.py create mode 100644 openedx_lor/authoring/rest_api/v1/libraries.py create mode 100644 openedx_lor/authoring/rest_api/v1/urls.py delete mode 100644 openedx_lor/authoring/urls.py delete mode 100644 openedx_lor/authoring/views.py diff --git a/openedx_learning/contrib/staticassets/migrations/0001_initial.py b/openedx_learning/contrib/staticassets/migrations/0001_initial.py deleted file mode 100644 index 239854b2..00000000 --- a/openedx_learning/contrib/staticassets/migrations/0001_initial.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 3.2.10 on 2022-08-14 01:44 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('itemstore', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Asset', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ], - ), - migrations.CreateModel( - name='ComponentVersionAsset', - fields=[ - ('component_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='itemstore.componentversion')), - ('asset', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='staticassets.asset')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/openedx_learning/core/itemstore/migrations/0001_initial.py b/openedx_learning/core/itemstore/migrations/0001_initial.py index eb06bc0e..a11e590e 100644 --- a/openedx_learning/core/itemstore/migrations/0001_initial.py +++ b/openedx_learning/core/itemstore/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.10 on 2022-08-14 01:44 +# Generated by Django 4.1 on 2022-09-05 01:49 import django.core.validators from django.db import migrations, models @@ -19,7 +19,7 @@ class Migration(migrations.Migration): name='Component', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), ('namespace', models.CharField(max_length=100)), ('type', models.CharField(blank=True, max_length=100)), ('identifier', models.CharField(max_length=255)), @@ -32,7 +32,7 @@ class Migration(migrations.Migration): name='ComponentVersion', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), ('created', models.DateTimeField()), ('component', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='itemstore.component')), ], @@ -41,7 +41,7 @@ class Migration(migrations.Migration): name='Item', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), ('identifier', models.CharField(max_length=255)), ('created', models.DateTimeField()), ('modified', models.DateTimeField()), @@ -52,7 +52,7 @@ class Migration(migrations.Migration): name='ItemVersion', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), ('title', models.CharField(blank=True, max_length=1000)), ], ), @@ -86,7 +86,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='itemversion', name='component_versions', - field=models.ManyToManyField(related_name='item_versions', through='itemstore.ItemVersionComponentVersion', to='itemstore.ComponentVersion'), + field=models.ManyToManyField(related_name='item_versions', through='itemstore.ItemVersionComponentVersion', to='itemstore.componentversion'), ), migrations.AddField( model_name='itemversion', @@ -96,7 +96,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='itemversion', name='learning_context_versions', - field=models.ManyToManyField(related_name='item_versions', through='itemstore.LearningContextVersionItemVersion', to='publishing.LearningContextVersion'), + field=models.ManyToManyField(related_name='item_versions', through='itemstore.LearningContextVersionItemVersion', to='publishing.learningcontextversion'), ), migrations.CreateModel( name='Content', @@ -123,12 +123,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='componentversion', name='contents', - field=models.ManyToManyField(related_name='component_versions', through='itemstore.ComponentVersionContent', to='itemstore.Content'), + field=models.ManyToManyField(related_name='component_versions', through='itemstore.ComponentVersionContent', to='itemstore.content'), ), migrations.AddField( model_name='componentversion', name='learning_context_versions', - field=models.ManyToManyField(related_name='component_versions', through='itemstore.LearningContextVersionComponentVersion', to='publishing.LearningContextVersion'), + field=models.ManyToManyField(related_name='component_versions', through='itemstore.LearningContextVersionComponentVersion', to='publishing.learningcontextversion'), ), migrations.AddConstraint( model_name='learningcontextversionitemversion', diff --git a/openedx_learning/core/publishing/api.py b/openedx_learning/core/publishing/api.py index 71533494..87557dd7 100644 --- a/openedx_learning/core/publishing/api.py +++ b/openedx_learning/core/publishing/api.py @@ -36,14 +36,12 @@ How to manage plugin cycle life? """ +from datetime import datetime, timezone +from django.db import transaction - -def current_version(learning_context_key): - pass - - -def update_published_version(learning_context_key, app_name, published_at=None): - pass - +from .models import LearningContext +def create_learning_context(identifier, title): + with transaction.atomic(): + LearningContext.objects.create(identifier=identifier, title=title) \ No newline at end of file diff --git a/openedx_learning/core/publishing/migrations/0001_initial.py b/openedx_learning/core/publishing/migrations/0001_initial.py index cf3b38be..83098ac1 100644 --- a/openedx_learning/core/publishing/migrations/0001_initial.py +++ b/openedx_learning/core/publishing/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.10 on 2022-07-21 04:01 +# Generated by Django 4.1 on 2022-09-05 01:49 from django.db import migrations, models import django.db.models.deletion @@ -17,19 +17,20 @@ class Migration(migrations.Migration): name='LearningContext', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), ('identifier', models.CharField(max_length=255)), ('created', models.DateTimeField()), + ('updated', models.DateTimeField()), + ('title', models.CharField(blank=True, max_length=1000)), ], ), migrations.CreateModel( name='LearningContextVersion', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), ('created', models.DateTimeField()), ('learning_context', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='publishing.learningcontext')), - ('prev_version', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='publishing.learningcontext')), ], ), migrations.AddConstraint( diff --git a/openedx_learning/core/publishing/migrations/0002_drop_prev_version.py b/openedx_learning/core/publishing/migrations/0002_drop_prev_version.py deleted file mode 100644 index ce5f2c2b..00000000 --- a/openedx_learning/core/publishing/migrations/0002_drop_prev_version.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.2.10 on 2022-08-14 04:35 - -from django.db import migrations, models -import uuid - - -class Migration(migrations.Migration): - - dependencies = [ - ('publishing', '0001_initial'), - ] - - operations = [ - migrations.RemoveField( - model_name='learningcontextversion', - name='prev_version', - ), - migrations.AlterField( - model_name='learningcontext', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID'), - ), - migrations.AlterField( - model_name='learningcontextversion', - name='uuid', - field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID'), - ), - ] diff --git a/openedx_learning/core/publishing/models.py b/openedx_learning/core/publishing/models.py index 396b9f05..263f2200 100644 --- a/openedx_learning/core/publishing/models.py +++ b/openedx_learning/core/publishing/models.py @@ -21,7 +21,10 @@ class LearningContext(models.Model): uuid = immutable_uuid_field() identifier = identifier_field() + title = models.CharField(max_length=1000, null=False, blank=False) + created = manual_date_time_field() + updated = manual_date_time_field() def __str__(self): return f"{self.identifier} ({self.uuid})" diff --git a/openedx_lor/authoring/migrations/0001_initial.py b/openedx_lor/authoring/migrations/0001_initial.py new file mode 100644 index 00000000..d8d420d7 --- /dev/null +++ b/openedx_lor/authoring/migrations/0001_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1 on 2022-09-05 01:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('publishing', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Library', + fields=[ + ('learning_context', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='publishing.learningcontext')), + ], + ), + ] diff --git a/openedx_learning/contrib/staticassets/migrations/__init__.py b/openedx_lor/authoring/migrations/__init__.py similarity index 100% rename from openedx_learning/contrib/staticassets/migrations/__init__.py rename to openedx_lor/authoring/migrations/__init__.py diff --git a/openedx_lor/authoring/models.py b/openedx_lor/authoring/models.py new file mode 100644 index 00000000..bce921a2 --- /dev/null +++ b/openedx_lor/authoring/models.py @@ -0,0 +1,12 @@ +from django.db import models +from openedx_learning.core.publishing.models import LearningContext + + +class Library(models.Model): + learning_context = models.OneToOneField( + LearningContext, + on_delete=models.CASCADE, + primary_key=True, + ) + + \ No newline at end of file diff --git a/openedx_lor/authoring/rest_api/__init__.py b/openedx_lor/authoring/rest_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_lor/authoring/rest_api/v1/__init__.py b/openedx_lor/authoring/rest_api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_lor/authoring/rest_api/v1/components.py b/openedx_lor/authoring/rest_api/v1/components.py new file mode 100644 index 00000000..04329dc6 --- /dev/null +++ b/openedx_lor/authoring/rest_api/v1/components.py @@ -0,0 +1,26 @@ +from rest_framework import viewsets +from rest_framework.response import Response + +from openedx_learning.core.itemstore.models import Item + + +class ComponentViewSet(viewsets.ViewSet): + + def list(self, request): + items = Item.objects.all() + return Response({'hello': 'world'}) + + def retrieve(self, request, pk=None): + return Response({'hello': 'world'}) + + def create(self, request): + return Response({'hello': 'world'}) + + def update(self, request, pk=None): + return Response({'hello': 'world'}) + + def partial_update(self, request, pk=None): + return Response({'hello': 'world'}) + + def destroy(self, request, pk=None): + raise NotImplementedError diff --git a/openedx_lor/authoring/rest_api/v1/items.py b/openedx_lor/authoring/rest_api/v1/items.py new file mode 100644 index 00000000..a16256cf --- /dev/null +++ b/openedx_lor/authoring/rest_api/v1/items.py @@ -0,0 +1,26 @@ +from rest_framework import viewsets +from rest_framework.response import Response + +from openedx_learning.core.itemstore.models import Item + + +class ItemViewSet(viewsets.ViewSet): + + def list(self, request): + items = Item.objects.all() + return Response({'hello': 'world'}) + + def retrieve(self, request, pk=None): + return Response({'hello': 'world'}) + + def create(self, request): + return Response({'hello': 'world'}) + + def update(self, request, pk=None): + return Response({'hello': 'world'}) + + def partial_update(self, request, pk=None): + return Response({'hello': 'world'}) + + def destroy(self, request, pk=None): + raise NotImplementedError diff --git a/openedx_lor/authoring/rest_api/v1/libraries.py b/openedx_lor/authoring/rest_api/v1/libraries.py new file mode 100644 index 00000000..db7d8a24 --- /dev/null +++ b/openedx_lor/authoring/rest_api/v1/libraries.py @@ -0,0 +1,27 @@ +from rest_framework import serializers, viewsets +from rest_framework.response import Response + +from ...models import Library + +class LibrarySerializer(serializers.ModelSerializer): + class Meta: + model = Library + fields = [ + 'learning_context', + # 'uuid', + # 'identifier', + # 'title', + # 'created', + # 'updated', + ] + +class LibraryViewSet(viewsets.ModelViewSet): + queryset = Library.objects.all().select_related('learning_context') + serializer_class = LibrarySerializer + +class LibraryViewSet2(viewsets.ViewSet): + + def list(self, request): + pass + + \ No newline at end of file diff --git a/openedx_lor/authoring/rest_api/v1/urls.py b/openedx_lor/authoring/rest_api/v1/urls.py new file mode 100644 index 00000000..1c4e1baf --- /dev/null +++ b/openedx_lor/authoring/rest_api/v1/urls.py @@ -0,0 +1,9 @@ +from rest_framework.routers import DefaultRouter + +from . import components, items, libraries + +router = DefaultRouter() +router.register(r'components', components.ComponentViewSet, basename='component') +router.register(r'items', items.ItemViewSet, basename='item') +router.register(r'libraries', libraries.LibraryViewSet, basename='library') +urlpatterns = router.urls diff --git a/openedx_lor/authoring/urls.py b/openedx_lor/authoring/urls.py deleted file mode 100644 index c4f23484..00000000 --- a/openedx_lor/authoring/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - path('item/', views.item_list, name='item_list'), -] diff --git a/openedx_lor/authoring/views.py b/openedx_lor/authoring/views.py deleted file mode 100644 index 5297dc62..00000000 --- a/openedx_lor/authoring/views.py +++ /dev/null @@ -1,14 +0,0 @@ -from pipes import Template -from django.template.response import TemplateResponse - -from openedx_learning.core.itemstore.models import Item - -def item_list(request): - items = Item.objects.all() - context = { - 'items': items, - } - return TemplateResponse(request, 'authoring/item/list.html', context) - -def item_create(request): - pass diff --git a/openedx_lor/urls.py b/openedx_lor/urls.py index ef11e1b5..9371a79d 100644 --- a/openedx_lor/urls.py +++ b/openedx_lor/urls.py @@ -1,5 +1,5 @@ from django.urls import include, path urlpatterns = [ - path('authoring/', include('openedx_lor.authoring.urls')), + path('authoring/v1/', include('openedx_lor.authoring.rest_api.v1.urls')), ] diff --git a/projects/dev.py b/projects/dev.py index b3905a39..bf80436a 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -43,6 +43,8 @@ # testing/iteration easier until the APIs stabilize. 'olx_importer.apps.OLXImporterConfig', + # LOR apps + 'rest_framework', 'openedx_lor.authoring.apps.AuthoringConfig', ) @@ -87,7 +89,7 @@ 'django.contrib.staticfiles.finders.AppDirectoriesFinder', ] STATICFILES_DIRS = [ - BASE_DIR / 'projects' / 'static' +# BASE_DIR / 'projects' / 'static' ] MEDIA_URL = '/media/' diff --git a/requirements/base.in b/requirements/base.in index c07753df..c0122e9d 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -8,3 +8,8 @@ attrs # Serialization pyyaml + +# Django Rest Framework + extras for the openedx_lor project +djangorestframework +markdown +django-filter diff --git a/requirements/base.txt b/requirements/base.txt index 8b663f92..ff9722db 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -11,8 +11,23 @@ attrs==22.1.0 backports-zoneinfo==0.2.1 # via django django==4.1 + # via + # -r requirements/base.in + # django-filter + # djangorestframework +django-filter==22.1 # via -r requirements/base.in +djangorestframework==3.13.1 + # via -r requirements/base.in +importlib-metadata==4.12.0 + # via markdown +markdown==3.4.1 + # via -r requirements/base.in +pytz==2022.2.1 + # via djangorestframework pyyaml==6.0 # via -r requirements/base.in sqlparse==0.4.2 # via django +zipp==3.8.1 + # via importlib-metadata diff --git a/requirements/ci.txt b/requirements/ci.txt index c5aba6ff..912932df 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -14,7 +14,7 @@ codecov==2.1.12 # via -r requirements/ci.in coverage==6.4.4 # via codecov -distlib==0.3.5 +distlib==0.3.6 # via virtualenv filelock==3.8.0 # via @@ -50,5 +50,5 @@ typing-extensions==4.3.0 # via import-linter urllib3==1.26.12 # via requests -virtualenv==20.16.3 +virtualenv==20.16.4 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index a56e0de6..71421be7 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -8,7 +8,7 @@ asgiref==3.5.2 # via # -r requirements/quality.txt # django -astroid==2.11.7 +astroid==2.12.5 # via # -r requirements/quality.txt # pylint @@ -77,14 +77,20 @@ dill==0.3.5.1 # via # -r requirements/quality.txt # pylint -distlib==0.3.5 +distlib==0.3.6 # via # -r requirements/ci.txt # virtualenv django==4.1 # via # -r requirements/quality.txt + # django-filter + # djangorestframework # edx-i18n-tools +django-filter==22.1 + # via -r requirements/quality.txt +djangorestframework==3.13.1 + # via -r requirements/quality.txt docutils==0.19 # via # -r requirements/quality.txt @@ -113,6 +119,7 @@ importlib-metadata==4.12.0 # via # -r requirements/quality.txt # keyring + # markdown # twine iniconfig==1.1.1 # via @@ -122,12 +129,16 @@ isort==5.10.1 # via # -r requirements/quality.txt # pylint +jaraco-classes==3.2.2 + # via + # -r requirements/quality.txt + # keyring jinja2==3.1.2 # via # -r requirements/quality.txt # code-annotations # diff-cover -keyring==23.8.2 +keyring==23.9.0 # via # -r requirements/quality.txt # twine @@ -135,6 +146,8 @@ lazy-object-proxy==1.7.1 # via # -r requirements/quality.txt # astroid +markdown==3.4.1 + # via -r requirements/quality.txt markupsafe==2.1.1 # via # -r requirements/quality.txt @@ -143,6 +156,10 @@ mccabe==0.7.0 # via # -r requirements/quality.txt # pylint +more-itertools==8.14.0 + # via + # -r requirements/quality.txt + # jaraco-classes networkx==2.8.6 # via # -r requirements/ci.txt @@ -202,7 +219,7 @@ pygments==2.13.0 # diff-cover # readme-renderer # rich -pylint==2.14.5 +pylint==2.15.0 # via # -r requirements/quality.txt # edx-lint @@ -228,7 +245,7 @@ pyparsing==3.0.9 # -r requirements/pip-tools.txt # -r requirements/quality.txt # packaging -pytest==7.1.2 +pytest==7.1.3 # via # -r requirements/quality.txt # pytest-cov @@ -241,6 +258,10 @@ python-slugify==6.1.2 # via # -r requirements/quality.txt # code-annotations +pytz==2022.2.1 + # via + # -r requirements/quality.txt + # djangorestframework pyyaml==6.0 # via # -r requirements/quality.txt @@ -331,7 +352,7 @@ urllib3==1.26.12 # -r requirements/quality.txt # requests # twine -virtualenv==20.16.3 +virtualenv==20.16.4 # via # -r requirements/ci.txt # tox diff --git a/requirements/doc.txt b/requirements/doc.txt index 5eb7c602..338ada5f 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -37,6 +37,13 @@ coverage[toml]==6.4.4 # -r requirements/test.txt # pytest-cov django==4.1 + # via + # -r requirements/test.txt + # django-filter + # djangorestframework +django-filter==22.1 + # via -r requirements/test.txt +djangorestframework==3.13.1 # via -r requirements/test.txt doc8==1.0.0 # via -r requirements/doc.in @@ -53,7 +60,10 @@ idna==3.3 imagesize==1.4.1 # via sphinx importlib-metadata==4.12.0 - # via sphinx + # via + # -r requirements/test.txt + # markdown + # sphinx iniconfig==1.1.1 # via # -r requirements/test.txt @@ -63,6 +73,8 @@ jinja2==3.1.2 # -r requirements/test.txt # code-annotations # sphinx +markdown==3.4.1 + # via -r requirements/test.txt markupsafe==2.1.1 # via # -r requirements/test.txt @@ -93,7 +105,7 @@ pyparsing==3.0.9 # via # -r requirements/test.txt # packaging -pytest==7.1.2 +pytest==7.1.3 # via # -r requirements/test.txt # pytest-cov @@ -107,7 +119,10 @@ python-slugify==6.1.2 # -r requirements/test.txt # code-annotations pytz==2022.2.1 - # via babel + # via + # -r requirements/test.txt + # babel + # djangorestframework pyyaml==6.0 # via # -r requirements/test.txt @@ -166,4 +181,6 @@ urllib3==1.26.12 webencodings==0.5.1 # via bleach zipp==3.8.1 - # via importlib-metadata + # via + # -r requirements/test.txt + # importlib-metadata diff --git a/requirements/quality.txt b/requirements/quality.txt index fcf8db39..615387d0 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -8,7 +8,7 @@ asgiref==3.5.2 # via # -r requirements/test.txt # django -astroid==2.11.7 +astroid==2.12.5 # via # pylint # pylint-celery @@ -47,6 +47,13 @@ coverage[toml]==6.4.4 dill==0.3.5.1 # via pylint django==4.1 + # via + # -r requirements/test.txt + # django-filter + # djangorestframework +django-filter==22.1 + # via -r requirements/test.txt +djangorestframework==3.13.1 # via -r requirements/test.txt docutils==0.19 # via readme-renderer @@ -56,7 +63,9 @@ idna==3.3 # via requests importlib-metadata==4.12.0 # via + # -r requirements/test.txt # keyring + # markdown # twine iniconfig==1.1.1 # via @@ -66,20 +75,26 @@ isort==5.10.1 # via # -r requirements/quality.in # pylint +jaraco-classes==3.2.2 + # via keyring jinja2==3.1.2 # via # -r requirements/test.txt # code-annotations -keyring==23.8.2 +keyring==23.9.0 # via twine lazy-object-proxy==1.7.1 # via astroid +markdown==3.4.1 + # via -r requirements/test.txt markupsafe==2.1.1 # via # -r requirements/test.txt # jinja2 mccabe==0.7.0 # via pylint +more-itertools==8.14.0 + # via jaraco-classes packaging==21.3 # via # -r requirements/test.txt @@ -108,7 +123,7 @@ pygments==2.13.0 # via # readme-renderer # rich -pylint==2.14.5 +pylint==2.15.0 # via # edx-lint # pylint-celery @@ -126,7 +141,7 @@ pyparsing==3.0.9 # via # -r requirements/test.txt # packaging -pytest==7.1.2 +pytest==7.1.3 # via # -r requirements/test.txt # pytest-cov @@ -139,6 +154,10 @@ python-slugify==6.1.2 # via # -r requirements/test.txt # code-annotations +pytz==2022.2.1 + # via + # -r requirements/test.txt + # djangorestframework pyyaml==6.0 # via # -r requirements/test.txt @@ -197,7 +216,6 @@ webencodings==0.5.1 wrapt==1.14.1 # via astroid zipp==3.8.1 - # via importlib-metadata - -# The following packages are considered to be unsafe in a requirements file: -# setuptools + # via + # -r requirements/test.txt + # importlib-metadata diff --git a/requirements/test.txt b/requirements/test.txt index 1cf26046..a055df20 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -22,11 +22,24 @@ code-annotations==1.3.0 # via -r requirements/test.in coverage[toml]==6.4.4 # via pytest-cov + # via + # -r requirements/base.txt + # django-filter + # djangorestframework +django-filter==22.1 + # via -r requirements/base.txt +djangorestframework==3.13.1 # via -r requirements/base.txt +importlib-metadata==4.12.0 + # via + # -r requirements/base.txt + # markdown iniconfig==1.1.1 # via pytest jinja2==3.1.2 # via code-annotations +markdown==3.4.1 + # via -r requirements/base.txt markupsafe==2.1.1 # via jinja2 packaging==21.3 @@ -39,7 +52,7 @@ py==1.11.0 # via pytest pyparsing==3.0.9 # via packaging -pytest==7.1.2 +pytest==7.1.3 # via # pytest-cov # pytest-django @@ -49,6 +62,10 @@ pytest-django==4.5.2 # via -r requirements/test.in python-slugify==6.1.2 # via code-annotations +pytz==2022.2.1 + # via + # -r requirements/base.txt + # djangorestframework pyyaml==6.0 # via # -r requirements/base.txt @@ -65,3 +82,7 @@ tomli==2.0.1 # via # coverage # pytest +zipp==3.8.1 + # via + # -r requirements/base.txt + # importlib-metadata From 658e3ac9e6eab29ae60ac3c6d2b34b75c4671381 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Fri, 16 Sep 2022 12:53:32 -0400 Subject: [PATCH 05/33] create a rest_api package --- .../rest_api/v1/libraries.py => libraries.py | 0 openedx_learning/core/itemstore/api.py | 47 ++++++++++++++++ openedx_learning/core/itemstore/data.py | 55 +++++++++++++++++++ .../commands/load_itemstore_sample_data.py | 42 +++++++++++--- .../rest_api}/__init__.py | 0 openedx_learning/rest_api/apps.py | 11 ++++ openedx_learning/rest_api/urls.py | 6 ++ .../rest_api/v1}/__init__.py | 0 .../rest_api/v1/components.py | 0 .../rest_api/v1/items.py | 0 .../rest_api/v1/urls.py | 3 +- openedx_lor/authoring/apps.py | 7 --- .../authoring/migrations/0001_initial.py | 22 -------- openedx_lor/authoring/migrations/__init__.py | 0 openedx_lor/authoring/models.py | 12 ---- openedx_lor/authoring/rest_api/__init__.py | 0 openedx_lor/authoring/rest_api/v1/__init__.py | 0 .../authoring/templates/authoring/base.html | 9 --- .../templates/authoring/item/list.html | 15 ----- openedx_lor/urls.py | 5 -- projects/dev.py | 4 +- projects/urls.py | 3 +- 22 files changed, 157 insertions(+), 84 deletions(-) rename openedx_lor/authoring/rest_api/v1/libraries.py => libraries.py (100%) create mode 100644 openedx_learning/core/itemstore/api.py create mode 100644 openedx_learning/core/itemstore/data.py rename {openedx_lor => openedx_learning/rest_api}/__init__.py (100%) create mode 100644 openedx_learning/rest_api/apps.py create mode 100644 openedx_learning/rest_api/urls.py rename {openedx_lor/authoring => openedx_learning/rest_api/v1}/__init__.py (100%) rename {openedx_lor/authoring => openedx_learning}/rest_api/v1/components.py (100%) rename {openedx_lor/authoring => openedx_learning}/rest_api/v1/items.py (100%) rename {openedx_lor/authoring => openedx_learning}/rest_api/v1/urls.py (67%) delete mode 100644 openedx_lor/authoring/apps.py delete mode 100644 openedx_lor/authoring/migrations/0001_initial.py delete mode 100644 openedx_lor/authoring/migrations/__init__.py delete mode 100644 openedx_lor/authoring/models.py delete mode 100644 openedx_lor/authoring/rest_api/__init__.py delete mode 100644 openedx_lor/authoring/rest_api/v1/__init__.py delete mode 100644 openedx_lor/authoring/templates/authoring/base.html delete mode 100644 openedx_lor/authoring/templates/authoring/item/list.html delete mode 100644 openedx_lor/urls.py diff --git a/openedx_lor/authoring/rest_api/v1/libraries.py b/libraries.py similarity index 100% rename from openedx_lor/authoring/rest_api/v1/libraries.py rename to libraries.py diff --git a/openedx_learning/core/itemstore/api.py b/openedx_learning/core/itemstore/api.py new file mode 100644 index 00000000..fec12264 --- /dev/null +++ b/openedx_learning/core/itemstore/api.py @@ -0,0 +1,47 @@ +# type: ignore +""" +Public APIs for manipulating Items in the ItemStore +""" +from typing import Optional +import datetime +import uuid + + +from .data import ComponentData, ItemData + + +def create_component( + learning_context_uuid: uuid.UUID, + namespace: str, + type: str, + identifier: str, + + uuid: Optional[uuid.UUID], + created: Optional[datetime.datetime], +) -> ComponentData: + pass + + +def create_item( + learning_context_uuid: uuid.UUID, + identifier: str, + uuid: Optional[uuid.UUID], +) -> ItemData: + pass + + +def sample_code(): + new_item = create_item(identifier) + + +def get_items(): + """ + Make a layer that iterates over the Queryset and does the casting into data + structures, eg. + + items = data_from_qset(Item.objects.all(), ItemData) + + """ + pass + + diff --git a/openedx_learning/core/itemstore/data.py b/openedx_learning/core/itemstore/data.py new file mode 100644 index 00000000..bc2ab837 --- /dev/null +++ b/openedx_learning/core/itemstore/data.py @@ -0,0 +1,55 @@ +from datetime import datetime +from typing import Dict, List +import uuid + +from attrs import define, field + + +@define +class LearningContextData: + uuid: uuid.UUID + identifier: str + title: str + + created: datetime + modified: datetime + + +@define +class ComponentData: + uuid: uuid.UUID + + # The combination of (learning_context, namespace, type, identifier) is + # unique, but the same namespace+type+identifier can exist in a different + # Learning Context. + learning_context: LearningContextData + namespace: str + type: str + identifier: str + + created: datetime + modified: datetime + + +@define +class ComponentVersionData: + component: ComponentData + created: datetime + + + +@define +class ItemData: + uuid: uuid.UUID + identifier: str + + learning_context: LearningContextData + + +@define +class ItemVersionData: + uuid: uuid.UUID + item: ItemData + title: str + + component_versions: List[ComponentVersionData] diff --git a/openedx_learning/core/itemstore/management/commands/load_itemstore_sample_data.py b/openedx_learning/core/itemstore/management/commands/load_itemstore_sample_data.py index 932e513f..159e23b1 100644 --- a/openedx_learning/core/itemstore/management/commands/load_itemstore_sample_data.py +++ b/openedx_learning/core/itemstore/management/commands/load_itemstore_sample_data.py @@ -1,5 +1,7 @@ """ Seed some sample data. + +This is going to use some model code directly for now. """ from datetime import datetime, timezone import logging @@ -14,7 +16,8 @@ LearningContext, LearningContextVersion ) from openedx_learning.core.itemstore.models import ( - Content, Component, ComponentVersion, LearningContextVersionComponentVersion + Content, Component, ComponentVersion, LearningContextVersionComponentVersion, + Item, ItemVersion ) from openedx_learning.lib.fields import create_hash_digest @@ -31,18 +34,39 @@ def add_arguments(self, parser): def handle(self, learning_context_identifier, itemstore_yaml_file, **options): self.learning_context_identifier = learning_context_identifier now = datetime.now(timezone.utc) - lc = LearningContext.objects.get_or_create( - identifier=learning_context_identifier, - defaults={'created': now}, - ) - load_itemstore_data(itemstore_yaml_file, lc) + + with transaction.atomic(): + lc = LearningContext.objects.get_or_create( + identifier=learning_context_identifier, + title="Placeholder Title", + defaults={ + 'created': now, + 'updated': now, + }, + ) + load_itemstore_data(itemstore_yaml_file, lc, now) -def load_itemstore_data(itemstore_yaml_file, learning_context): +def load_itemstore_data(itemstore_yaml_file, learning_context, now): data = yaml.safe_load(itemstore_yaml_file) for identifier, item_data in data['items'].items(): - pass + create_or_update_item(learning_context, identifier, item_data, now) + + +def create_or_update_item(learning_context, identifier, item_data, now): + item = Item.objects.get_or_create( + learning_context=learning_context, + identifier=identifier, + defaults={ + 'created': now, + 'updated': now, + } + ) + +def create_or_update_copmonent(learning_context, identifier, component_data): + pass +def create_or_update_content(learing_context, identifier, content_data): + pass - print(data) diff --git a/openedx_lor/__init__.py b/openedx_learning/rest_api/__init__.py similarity index 100% rename from openedx_lor/__init__.py rename to openedx_learning/rest_api/__init__.py diff --git a/openedx_learning/rest_api/apps.py b/openedx_learning/rest_api/apps.py new file mode 100644 index 00000000..7d2598f6 --- /dev/null +++ b/openedx_learning/rest_api/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class RESTAPIConfig(AppConfig): + """ + Configuration for the Learning Core REST API Django app. + """ + name = "openedx_learning.rest_api" + verbose_name = "Learning Core: REST API" + default_auto_field = 'django.db.models.BigAutoField' + \ No newline at end of file diff --git a/openedx_learning/rest_api/urls.py b/openedx_learning/rest_api/urls.py new file mode 100644 index 00000000..4afabad9 --- /dev/null +++ b/openedx_learning/rest_api/urls.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path('v1/', include('openedx_learning.rest_api.v1.urls')) +] diff --git a/openedx_lor/authoring/__init__.py b/openedx_learning/rest_api/v1/__init__.py similarity index 100% rename from openedx_lor/authoring/__init__.py rename to openedx_learning/rest_api/v1/__init__.py diff --git a/openedx_lor/authoring/rest_api/v1/components.py b/openedx_learning/rest_api/v1/components.py similarity index 100% rename from openedx_lor/authoring/rest_api/v1/components.py rename to openedx_learning/rest_api/v1/components.py diff --git a/openedx_lor/authoring/rest_api/v1/items.py b/openedx_learning/rest_api/v1/items.py similarity index 100% rename from openedx_lor/authoring/rest_api/v1/items.py rename to openedx_learning/rest_api/v1/items.py diff --git a/openedx_lor/authoring/rest_api/v1/urls.py b/openedx_learning/rest_api/v1/urls.py similarity index 67% rename from openedx_lor/authoring/rest_api/v1/urls.py rename to openedx_learning/rest_api/v1/urls.py index 1c4e1baf..72ea7b9a 100644 --- a/openedx_lor/authoring/rest_api/v1/urls.py +++ b/openedx_learning/rest_api/v1/urls.py @@ -1,9 +1,8 @@ from rest_framework.routers import DefaultRouter -from . import components, items, libraries +from . import components, items router = DefaultRouter() router.register(r'components', components.ComponentViewSet, basename='component') router.register(r'items', items.ItemViewSet, basename='item') -router.register(r'libraries', libraries.LibraryViewSet, basename='library') urlpatterns = router.urls diff --git a/openedx_lor/authoring/apps.py b/openedx_lor/authoring/apps.py deleted file mode 100644 index c7f37aef..00000000 --- a/openedx_lor/authoring/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class AuthoringConfig(AppConfig): - name = "openedx_lor.authoring" - verbose_name = "Open edX LOR: Authoring" - default_auto_field = 'django.db.models.BigAutoField' diff --git a/openedx_lor/authoring/migrations/0001_initial.py b/openedx_lor/authoring/migrations/0001_initial.py deleted file mode 100644 index d8d420d7..00000000 --- a/openedx_lor/authoring/migrations/0001_initial.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.1 on 2022-09-05 01:49 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('publishing', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Library', - fields=[ - ('learning_context', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='publishing.learningcontext')), - ], - ), - ] diff --git a/openedx_lor/authoring/migrations/__init__.py b/openedx_lor/authoring/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openedx_lor/authoring/models.py b/openedx_lor/authoring/models.py deleted file mode 100644 index bce921a2..00000000 --- a/openedx_lor/authoring/models.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.db import models -from openedx_learning.core.publishing.models import LearningContext - - -class Library(models.Model): - learning_context = models.OneToOneField( - LearningContext, - on_delete=models.CASCADE, - primary_key=True, - ) - - \ No newline at end of file diff --git a/openedx_lor/authoring/rest_api/__init__.py b/openedx_lor/authoring/rest_api/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openedx_lor/authoring/rest_api/v1/__init__.py b/openedx_lor/authoring/rest_api/v1/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openedx_lor/authoring/templates/authoring/base.html b/openedx_lor/authoring/templates/authoring/base.html deleted file mode 100644 index 55c85f58..00000000 --- a/openedx_lor/authoring/templates/authoring/base.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - {% block title %}Authoring{% endblock %} - - - {% block content %}{% endblock%} - - \ No newline at end of file diff --git a/openedx_lor/authoring/templates/authoring/item/list.html b/openedx_lor/authoring/templates/authoring/item/list.html deleted file mode 100644 index 5b8f9952..00000000 --- a/openedx_lor/authoring/templates/authoring/item/list.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "../base.html" %} - -{% block title %}Items{% endblock %} - -{% block content %} - - -{% for item in items %} - - - -{% endfor %} -
{{ item.title }}
- -{% endblock %} diff --git a/openedx_lor/urls.py b/openedx_lor/urls.py deleted file mode 100644 index 9371a79d..00000000 --- a/openedx_lor/urls.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.urls import include, path - -urlpatterns = [ - path('authoring/v1/', include('openedx_lor.authoring.rest_api.v1.urls')), -] diff --git a/projects/dev.py b/projects/dev.py index bf80436a..e5d741a8 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -43,9 +43,9 @@ # testing/iteration easier until the APIs stabilize. 'olx_importer.apps.OLXImporterConfig', - # LOR apps + # REST API 'rest_framework', - 'openedx_lor.authoring.apps.AuthoringConfig', + 'openedx_learning.rest_api.apps.RESTAPIConfig', ) MIDDLEWARE = [ diff --git a/projects/urls.py b/projects/urls.py index 277a206a..92928a09 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -4,5 +4,6 @@ urlpatterns = [ path('admin/doc/', include('django.contrib.admindocs.urls')), path('admin/', admin.site.urls), - path('', include('openedx_lor.urls')) + + path('rest_api/', include('openedx_learning.rest_api.urls')) ] From e95af5c6eaa1ba9224b61b9d5421bf6a5a5267cf Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Thu, 2 Feb 2023 17:29:42 -0700 Subject: [PATCH 06/33] itemstore features, pre-Denver --- openedx_learning/core/itemstore/api.py | 124 ++++++++++++++++-- openedx_learning/core/itemstore/data.py | 17 ++- .../management/commands/load_dummy_data.py | 76 +++++++++++ .../commands/load_itemstore_sample_data.py | 2 +- openedx_learning/core/itemstore/models.py | 10 +- openedx_learning/rest_api/v1/items.py | 22 ++++ openedx_learning/rest_api/v1/urls.py | 1 + 7 files changed, 234 insertions(+), 18 deletions(-) create mode 100644 openedx_learning/core/itemstore/management/commands/load_dummy_data.py diff --git a/openedx_learning/core/itemstore/api.py b/openedx_learning/core/itemstore/api.py index fec12264..d410e080 100644 --- a/openedx_learning/core/itemstore/api.py +++ b/openedx_learning/core/itemstore/api.py @@ -1,13 +1,19 @@ -# type: ignore """ Public APIs for manipulating Items in the ItemStore """ -from typing import Optional -import datetime +from typing import Optional, List +from datetime import datetime, timezone +import textwrap import uuid -from .data import ComponentData, ItemData +from openedx_learning.lib.fields import create_hash_digest + +from .data import ( + ContentData, ComponentData, + ComponentVersionData, ItemData, ItemVersionData, LearningContextData, + SavedItemData +) def create_component( @@ -22,16 +28,116 @@ def create_component( pass -def create_item( - learning_context_uuid: uuid.UUID, - identifier: str, +def create_item(ItemData) -> SavedItemData: + pass + + +def create_item(learning_context_uuid, identifier, ) -> SavedItemData: + pass + + + +def create_item_version( + item: ItemData, + title: str, uuid: Optional[uuid.UUID], -) -> ItemData: + component_versions: List[ComponentVersionData], +) -> ItemVersionData: + return None + +def create_component_version( + +) -> ComponentVersionData: + return None + + + +def get_item(item_uuid): + pass + + +def get_item_version(item_version_uuid): pass +def fake_item_version(item_version_uuid): + now = datetime.now(timezone.utc) + + lcd = LearningContextData( + uuid=uuid.uuid4(), + identifier="intro_courselet", + title="Open edX LMS Basics", + created=now, + modified=now, + ) + + item = ItemData( + uuid=uuid.uuid4(), + identifier="what_is_modulestore", + learning_context=lcd, + ) + + + + olx_bytes = textwrap.dedent(""" + + +

Pretend this is a longer intro.

+ + + a storage mechanism for XBlocks and XModules + a runtime for XBlocks and XModules + a giant headache + a wee, harmless little bunny + +
+
""" + ).encode('utf-8') + mkd_cont = ContentData( + learning_context=lcd, + hash_digest=create_hash_digest(olx_bytes), + type='text' + ) + mkd_comp = ComponentData( + uuid=uuid.uuid4(), + learning_context=lcd, + namespace='xblock.v1', + type='markdown', + identifier='what_is_modulestore', + created=now, + modified=now, + ) + mkd_comp_v = ComponentVersionData( + uuid=uuid.uuid4(), + component=mkd_comp, + ) + + return ItemVersionData( + uuid=item_version_uuid, + item=item, + title="What is Modulestore?", + component_versions=[ + ComponentVersionData( + uuid=uuid.uuid4(), + component=None + created=now, + ) + ] + ) + + def sample_code(): - new_item = create_item(identifier) + item_data = create_item(learning_context.id, identifier) + create_item_version( + item_data.uuid, + + ) + + + [ + ComponentVersionData + ] + ) def get_items(): diff --git a/openedx_learning/core/itemstore/data.py b/openedx_learning/core/itemstore/data.py index bc2ab837..aed34c96 100644 --- a/openedx_learning/core/itemstore/data.py +++ b/openedx_learning/core/itemstore/data.py @@ -33,18 +33,19 @@ class ComponentData: @define class ComponentVersionData: + uuid: uuid.UUID component: ComponentData created: datetime - @define class ItemData: - uuid: uuid.UUID identifier: str - learning_context: LearningContextData +@define +class SavedItemData(ItemData): + uuid: uuid.UUID @define class ItemVersionData: @@ -53,3 +54,13 @@ class ItemVersionData: title: str component_versions: List[ComponentVersionData] + + +@define +class ContentData: + learning_context: LearningContextData + hash_digest: str # should this be bytes instead? + type: str + sub_type: str + size: int + created: datetime diff --git a/openedx_learning/core/itemstore/management/commands/load_dummy_data.py b/openedx_learning/core/itemstore/management/commands/load_dummy_data.py new file mode 100644 index 00000000..41c83152 --- /dev/null +++ b/openedx_learning/core/itemstore/management/commands/load_dummy_data.py @@ -0,0 +1,76 @@ +""" +Seed some sample data. + +This is going to use some model code directly for now. +""" +from datetime import datetime, timezone +import logging +import textwrap + +from django.core.management.base import BaseCommand +from django.db import transaction + +import yaml + +from openedx_learning.contrib.staticassets.models import Asset, ComponentVersionAsset +from openedx_learning.core.publishing.models import ( + LearningContext, LearningContextVersion +) +from openedx_learning.core.itemstore.models import ( + Content, Component, ComponentVersion, LearningContextVersionComponentVersion, + Item, ItemVersion +) +from openedx_learning.lib.fields import create_hash_digest + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Load dummy sample data' + + def handle(self, **options): + learning_context_identifier = "dummy_library" + now = datetime.now(timezone.utc) + + with transaction.atomic(): + lc = LearningContext.objects.create( + identifier=learning_context_identifier, + title="Dummy Library", + created=now, + updated=now, + ) + video_item = create_video_item(lc, now) + + +def create_video_item(learning_context, now): + video_item = Item.objects.create( + learning_context=learning_context, + identifier="intro_video", + created=now, + modified=now, + ) + video_comp = Component.objects.create( + learning_context=learning_context, + namespace='xblock.v1', + type='video', + identifier='intro', + created=now, + modified=now, + ) + video_xml_bytes = textwrap.dedent(""" +