Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Another pass at basic content models #13

Merged
merged 11 commits into from
Aug 7, 2022
Merged

Conversation

ormsbee
Copy link
Contributor

@ormsbee ormsbee commented May 26, 2022

This splits Item-related models into a new app and otherwise follows the logical split discussed here.

Copy link
Member

@kdmccormick kdmccormick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I took so long to review this. Most of my comments are just me trying to make sure I grok everything.

projects/dev.py Outdated
Comment on lines 34 to 37
# Learning Core Apps
'openedx_learning.apps.core.compose.apps.ComposeConfig',
'openedx_learning.apps.core.itemstore.apps.ItemStoreConfig',
'openedx_learning.apps.core.publish.apps.PublishConfig',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm aware these comments are superficial & tangential to the PR, but I ought to express them now before anyone starts using this repo :)

  • The apps namespace package seems unnecessary and out-of-step with other edx-platform libraries. Why not just openedx_learning.core.itemstore?
  • I notice that you are using verbs for some of the core app names. Wouldn't it be more standard to use nouns? (composition, publishing)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The apps namespace package seems unnecessary and out-of-step with other edx-platform libraries. Why not just openedx_learning.core.itemstore?

Mostly because I wanted a place to hang lib for things like the field types.

I notice that you are using verbs for some of the core app names. Wouldn't it be more standard to use nouns? (composition, publishing)

Yeah, I was going for the verb form for a while before because it was shorter, before I landed on itemstore for one of them. I'll switch it back over to nouns.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly because I wanted a place to hang lib for things like the field types.

The 2nd-level packages could be openedx_learning.core, openedx_learning.lib, and openedx_learning.contrib.

I remember as a new dev being frazzled by the long import paths of openedx.core.djangoapps... in edx-platform. I could never remember the paths; I was always copying and pasting, and it made the platform feel more overwhelming.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I'll give that a shot. I do like not having apps appear twice in that path (package + apps.py). Thank you!

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.{block_type}+xml'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

XBlocks are installed under the entrypoint xblock.v1. I don't think I have my head around the potential consequences of which MIME types we choose, so I'll leave it up to you whether or not you want to add the "v1" namespace here.

Suggested change
mime_type = f'application/vnd.openedx.xblock.{block_type}+xml'
mime_type = f'application/vnd.openedx.xblock.v1.{block_type}+xml'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right. I guess it doesn't hurt to add the v1, though I'm skeptical we'll ever have a v2. (I'm guessing we'd cali it something entirely different, to avoid confusion.) Will fix.

Comment on lines 188 to 198
continue


static_file_paths = frozenset(
f"static/{static_reference_path}"
for static_reference_path in re.findall(static_files_regex, data_str)
)
for static_file_path in static_file_paths:
if static_file_path in static_asset_paths_to_atom_ids:
atom_id = static_asset_paths_to_atom_ids[static_file_path]
ContentObjectPart.objects.create(content_object=content_obj, content_atom_id=atom_id, identifier=static_file_path)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dead code

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh, right, because I didn't ever put the static asset mapping stuff back. Thank you.

return paths_to_item_raw_ids


def import_block_type(self, block_type, content_path, static_asset_paths_to_atom_ids, item_raw_id_cache, now):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 I know it's just a temporary management command, but it is cool and helpful for me to read some code that uses these models that we've been discussing!

class Meta:
constraints = [
models.UniqueConstraint(
fields=["learning_context_id", "identifier"],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From this I'm deducing that two Items in different LearningContexts could share the same identifier. Is that right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I think that in general, the identifier fields have to be namespaced so that it's entirely local to one particular LearningContext.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case of XBlocks, do you see this identifier as being the usage key (block-v1:a+b+c+type@video+block@xyz) or the block id (just xyz)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer not to have the full learning context key encoded, but since we currently allow blocks of different types to have the same ID, I think we have to encode at least something like video+xyz


TODO: Size limit thoughts–configurable? Start at 10 MB?
"""
learning_context = models.ForeignKey(LearningContext, on_delete=models.CASCADE)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for some item_info, will item_info.learning_context always be equal to item_info.item_raw.learning_context?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. It's mostly there for ownership/cleanup.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool. This would be helpful to note in a comment.

uuid = immutable_uuid_field()
item_info = models.ForeignKey('ItemInfo', on_delete=models.RESTRICT)
item = models.ForeignKey(Item, on_delete=models.CASCADE)
title = models.CharField(max_length=1000, blank=True, null=True)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is title here and not in ItemInfo? Is it because it's example of policy, which we're distinguishing from metadata (like type)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Renaming is desirable for libraries, because you don't know what kind of convention that library is using, e.g. "Problem 22a" may not make as much sense for the new context it's being used in.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be title_override then to clarify that you can optionally stick with whatever the library author gave as a title?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm inclined to not use the _override convention because the default content in the library would also be using this field (just under the content library's learning context).

Though maybe the title should be lifted out into a separate model? Maybe even one with language awareness?

Wait... maybe ItemRaw:ItemVersion is M:1?

I'll make comments along both of those lines for future followup, but won't make any immediate model changes.

Comment on lines 31 to 36
# TODO: These pointers to the latest published version are convenient, but
# we don't currently have the data integrity guarantees to make sure that
# multiple versions aren't active at the same time. Maybe add a ref to the
# Item model in LearningContextVersionItemVersion, so we can force that
# constraint?
published_version = models.ForeignKey('ItemVersion', on_delete=models.RESTRICT, null=True, related_name="+")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

knowledge check: this is secondary source of information, since one could authoritatively determine this by asking the publish app what the published LearningContextVersion is, and then looking at the ItemVersions within that LCV, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. And maybe it's not a great idea to have redundancy like this. The main motivation for this was that a lot of the time, you want to bind data against the abstract notion of a piece of content across all its versions (e.g. most student state), and I wanted a really convenient way to get to the current "live" version from there.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does sound convenient. I guess, you're either going to end up needing to keep this field up-to-date, or you're going to end up with a lot of requests making the same query ("what's the published version?"). I don't think I have the experience to know which of these is an easier challenge to manage.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about making published_version a [memoized] python getter that uses the publish app to return the published version? Or do you mean specifically you want to be able to use the django ORM methods on this published_version field so you can select_related and do things like filtering queries?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or do you mean specifically you want to be able to use the django ORM methods on this published_version field so you can select_related and do things like filtering queries?

Yeah, exactly this. I want to make sure we can still do efficient filtering queries. But I think I'll actually leave this out for now and add it in later if it becomes necessary. Even if we do keep this kind of a key, it might make more sense in a separate model that specifically separates the publishing details (published_version, first_published_at, last_published_at, etc.).

Comment on lines 2 to 10
The item model hiearachy is: Item -> ItemVersion -> ItemInfo -> ItemRaw

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). ItemInfo
covers basic metadata that is intrinsic to the item itself, and now how it's
used in a LearningContext. ItemRaw represents the raw byte data.

TODO: Add link to ADR after it merges.
Copy link
Member

@kdmccormick kdmccormick Jun 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack, sorry, I am struggling to rebuild this hierarchy in my head. I'm going to try to rephrase it here. Let me know if I'm off:

  • ...Assume "LC" is some LearningContext, and "LCV" some LearningContextVersion within LC.
  • An ItemRaw is a blob of byte data and a mimetype.
    • Example: an ItemRaw instance "PR", containing some OLX file
  • An ItemInfo associates an ItemRaw with metadata, including its LearningContext and its item type.
    • Example: an ItemInfo instance "PI", which tells us that PR is a problem and belongs to LC.
  • An Item is an atomic block of content associated with an LC. In isolation, an Item does not tell us about its content, because content is associated with versions, whereas an Item is versionless.
    • Example: an Item instance "P" in LC.
  • An ItemVersion joins together an abstract Item instance with a concrete ItemInfo, denoting a particular version of the content, policy, and metadata of an Item.
    • Example: an ItemVersion instance "PV", joining together P and PI.
    • Example: an ItemVersion instance "PV2", joining together P and PI, with some updated policy.
    • Example: a later ItemVersion instance "PVx", joining together P and some modified ItemInfo "PIx".
    • and so on
  • LearningContextVersionItemVersions relate LearningContextVersions to ItemVersions. This table is the connective tissue telling us which ItemVersions are in a LearningContextVersion (and, by corollary, which Items are in a LearningContext).
    • Example: PV is in LCV
    • Example: PV2 is in "LCV2", a later version of LC
    • Example: PV2 is also in "LCV3", a later version of LC that updates some Item(s) other than P

"""
uuid = immutable_uuid_field()
learning_context = models.ForeignKey(LearningContext, on_delete=models.CASCADE)
type = models.CharField(max_length=100)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we always deduce type from item_raw.mime_type?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had originally meant it as something different, but I'm not sure if that holds up in the latest iteration.

The difference I had in mind originally was something like an HTMLBlock's HTML. That it would have an ItemInfo that showed it was an XBlock with HTML handler, but the raw data had a MIME type of "text/html".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. So I guess it depends whether we want to stuff OLX into the item_raws of xblock-typed ItemInfos, or something smarter like the constituent HTML / Loncapa XML / image bytes.

Comment on lines 101 to 103
The other fields on ItemRaw 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ormsbee I am struggling a bit with the distinction between ItemInfo and ItemRaw.

What would we lose by collapsing them into a single model? Is there something bad about structured metadata models hanging off of the same model that's storing the raw bytes?

Copy link
Contributor Author

@ormsbee ormsbee Jun 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I originally created the idea of ItemInfo (under a different name), when I was thinking it would have more stuff in it (like a local identifier associated with the content package), but those concepts were simplified out of the design. We would have to be careful to not pull back the raw payload all the time, but that's manageable.

The only remaining use case I can think of is when you want to add a little bit of metadata about the content that is not the content itself–new information entered by a human, not something that's being derived by the code from an ItemRaw. At that point, we have to figure out where to hang that data in a way that also clearly signals that this content has changed.

As an example, let's say we have a text description of an image.

If we attach that data to a model that hangs off of ItemRaw, then there's no indication that the content has been updated. None of the versioning mechanisms above this were invoked or needed to be invoked. So we've done an unexpected mutation, and it will invalidate assumptions around versioning and caching. We can't even create a new ItemRaw to bind it to (wasteful as that would be in terms of space), because ItemRaw uses a hash of its contents for identity.

We could attach that data to a model that hangs off of ItemVersion. That's a little weird because that's not policy data that changes from course to course, but we would have default policy information that's attached at that layer and copied, so maybe it would work out...?

You're right in that other comment, it's very much like the definition/usage split in the old system. But maybe we should take that as a cautionary tale–people put many things in the settings scope that should have been put in definitions. If the distinction of when to use ItemInfo is unclear to you as an expert in this kind of system, then it will almost certainly confuse others who try to use this later. Maybe it's easier to say: "If you can completely derive it from the raw data, hang a model off of ItemRaw, and if it's any new data entered in by a user, hang it off of ItemVersion."?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized I need to check an assumption about how I've been reading this: "hanging model X off of model Y" means that X would have a foreign key to Y, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized I need to check an assumption about how I've been reading this: "hanging model X off of model Y" means that X would have a foreign key to Y, right?

Yes. I should have been clearer. 😄

@ormsbee
Copy link
Contributor Author

ormsbee commented Jun 9, 2022

FYI @bradenmacdonald: This is the data models PR I briefly mentioned in our meeting today.

@kdmccormick
Copy link
Member

Hey @ormsbee , are you looking for another round of review on this one?

@ormsbee
Copy link
Contributor Author

ormsbee commented Jul 27, 2022

@kdmccormick: Almost. I've been doing bits and pieces in the odd-hours for this. I need to update the import script to make sure these ideas all hold together and then it'll be ready for review. Though I don't expect the models.py files to change very much at this point, so if you want to take another pass at that, you're more than welcome to.

@ormsbee
Copy link
Contributor Author

ormsbee commented Jul 27, 2022

@kdmccormick: ready for another round of review, with the caveat that I'd ask that you largely ignore load_course_data.py and the staticassets app at this point. I wanted to make sure it was functional (i.e. the data model wasn't totally broken), but the interface is completely wrong for the import script and staticassets is pretty half-baked at the moment.


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
potentially M:1 with your data model.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for some instance of this, say ItemVersionData -> Data:

  • is Data immutable?
  • is ItemVersionData:Data an M:1 relationship because multiple versions of an Item may reference the same underlying Data?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is Data immutable

It should be or Bad Things will happen.

is ItemVersionData:Data an M:1 relationship because multiple versions of an Item may reference the same underlying Data?

Yes. One of the underlying assumptions here is that if you have some data that's associated with an ItemVersion, changing that data means that the Item has changed in some way, and thus forms a new ItemVersion. So if you change the retry-settings or some such for an Item, there's a new ItemVersion. But for all the other apps that didn't change their data, we can just make new entries that join the new ItemVersion with the same data that those apps had for the last ItemVersion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ormsbee

It should be or Bad Things will happen.

Cool, I assumed so but wanted to be sure. Is there a way to indicate that, like marking it read-only in Django admin?

Yes. ...

Makes sense 👍🏻 It would be good to include that explanation somewhere in the library before releasing v1.0. Not a blocker for this PR; I know you're still iterating.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, I assumed so but wanted to be sure. Is there a way to indicate that, like marking it read-only in Django admin?

Yeah, I'll definitely do that when I start making some admin pages.

Comment on lines 174 to 176
# TODO: maybe we should split it up into two fields instead of doing
# substring searches?
mime_type = models.CharField(max_length=255, blank=False, null=False)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should split it up into two fields instead of doing substring searches?

Sounds like a win to me. Any downsides?

Another reason to split: MIME types can include a parameter field, eg text/plain;charset=utf8. If you don't want to support the parameter field, then splitting into two fields would make that more explicit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I'm just used to thinking them as a combined thing because I can't really see querying one in isolation from the other. But it is more normalized and it's easy to add, so 🤷 I'll add it.

Minimal abstract model to let people attach data to ItemVersions.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I'm reading that:

  • each ItemVersion can only be associated with at most one of each type of "Data" model (Image, Asset, Video, etc.), but
  • each ItemVersion may be associated with an arbitrary number of Content objects.

This confused me at first, because the "Data" models and the Content model occupy very similar roles in my head. But I think I'm beginning to understand it... is this a good example?

  • Some ItemVersion represents a video.
    • It has multiple Content items associated with: one that points to the video file, one PNG thumbnail, and several others text files that are transcripts.
    • In the staticassets app, it has a single associated Video instance and Asset instance. Video metadata as well as information about the transcripts are part of (or hang off) of the Video model.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that was my intent. A couple of extra notes about it:

Content survives after apps die.

At some point, an app becomes unsupported and disappears. At that point, all the Item/ItemVersion-related data it has dies as well. But Content is associated with the ItemVersion directly in itemstore, so that raw data will still export.

So I think this encourages a pattern where we store raw things in Content and then build smarter relational data structures out of them connected to ItemVersion. For instance, a ProblemBlock's raw OLX can live in Content while richer relational tables live in a new ProblemBlock app that give a richer, more queryable interface.

ItemVersion mapping to just one row in a "Data" model is common, but not required.

I think that most of the time apps want to associate one piece of data with a given ItemVersion (e.g. a problem weight, schedule, etc.), but that's not a requirement. A rich data model of a ProblemBlock might want to create entries for each response, for instance.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ItemVersion mapping to just one row in a "Data" model is common, but not required.

How would that look?

For example, imagine we have an ItemVersion representing a problem block, and Response data models that we want to (indirectly) hang off of it. Would it look like:

ItemVersion  <--(1:1)-- ItemVersionProblemBlock --(N:N)--> Response

or:

ItemVersion  <--(1:N)-- ItemVersionResponse --(N:1)--> Response

or something else?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I thought it would be like:

ItemVersion <-- (N:1) -- ItemVersionProblemBlock -- (N:N) --> Response

That would still allow some central process to increment to the next version in a simple way without understanding what ItemVersionProblemBlock is. But I honestly haven't thought it through that much.

Copy link
Contributor

@feanil feanil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of questions but nothing blocking this from landing. The re-organization generally makes sense to me based on previous conversations.


Each ItemVersion belongs to one and only one Item.

TODO: created_by field?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to confirm, I would see this as something that would be useful for debugging and analyzing but not as a thing that would bubble out to an end user.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that was my thought as well.

Comment on lines 76 to 78
# Question: Title is the only thing here that actually has human-readable
# text. Does it make sense to lift it out into a separate metadata model,
# possibly even one with language awareness?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think maybe this is a low level enough thing that we don't need to do that. I'm thinking about how the contents if they're XBlocks for example, will probably have a specific language and so maybe it makes more sense to build language awareness at a higher level?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think you're right that it belongs in a higher level. In any case, I'm definitely fine with punting this for now, even if we do bake it at this layer later. I'll remove the comment.

but changing it will affect all ItemVersions.
"""
uuid = immutable_uuid_field()
learning_context = models.ForeignKey(LearningContext, on_delete=models.CASCADE)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So an item can only be in one LearningContext(LC), but since an ItemVersion can be in any number of LearningContextVersions(LCV) do we need a constraint somewhere that ensures that all those LCVs are from the same LC? Or is that too paranoid at this stage?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like being paranoid about the data model in general, but I'm not sure what we can do with MySQL here, or how much overhead adding CheckConstraints is going to cause. I'll see how hard it is.

Comment on lines 174 to 175
# TODO: maybe we should split it up into two fields instead of doing
# substring searches?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would we need to do a partial search? Is it just when looking at types and ignoring sub-types? or something more complex?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's the only situation I can really think of.

@ormsbee ormsbee merged commit 3125cb9 into openedx:main Aug 7, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

4 participants