Skip to content

[FC-0009] New django app for copying and pasting OLX content in Studio#31904

Merged
bradenmacdonald merged 19 commits intoopenedx:masterfrom
open-craft:copy-olx-backend-app
Apr 14, 2023
Merged

[FC-0009] New django app for copying and pasting OLX content in Studio#31904
bradenmacdonald merged 19 commits intoopenedx:masterfrom
open-craft:copy-olx-backend-app

Conversation

@bradenmacdonald
Copy link
Contributor

@bradenmacdonald bradenmacdonald commented Mar 10, 2023

Description

This implements openedx/modular-learning#51 and openedx/modular-learning#10

It is implemented as a more generic "staged content" area, where "staged content" is any content that isn't part of a particular course or library at the moment. Staged content is not editable; you have to paste it somewhere to edit it. We anticipate that "partial import" functionality may be another use case for staged content in addition to this clipboard functionality.

It's currently implemented as a django app plugin within edx-platform because it's highly coupled to platform internals and we want it tested as part of the test suite. However, if preferable we could move it to an external repo.

I am hoping to use #31903 to enforce that no other code imports the private API of this new app.

Supporting information

See GitHub issue linked above.

Analytics

When a user copies something to their clipboard, the CMS will log something like this:

2023-03-17 00:15:58,445 INFO 465 [openedx.core.djangoapps.content_staging.views] [user 4] [ip 172.18.0.1] views.py:142 - 
User 4 copied video component "block-v1:DemoX+D+EMO+type@video+block@56a60936c4c246ac861cfb4419cf235e" to their clipboard.

TODOs:

Testing instructions

  1. Go to http://studio.local.overhang.io:8001/admin/waffle/flag/ (or devstack/sandbox equivalent)
  2. Add a new flag, contentstore.enable_copy_paste_feature and enable it for Everyone
  3. Go to a Unit page in Studio and use the new actions menu (see [FC-0009] New actions menu for editing components in Studio (behind waffle flag) #31853). The "Copy" action should now work.
  4. You won't be able to paste, but you can go to http://studio.local.overhang.io:8001/api/content-staging/v1/clipboard/ to see the clipboard contents. That contains an olx_url that you can go to in turn to download the OLX as an .xml file.

How to test asynchronous copying

copying

Copying is currently done synchronously, but the backend and frontend code do already support long-running asynchronous copy operations. To simulate this for testing:

Click to view instructions

First, make the following edit:

Screenshot 2023-03-16 at 3 36 36 PM

Then copy an XBlock. The copying process will continue until you retrieve the OLX. To retrieve the OLX, open the Network inspector and in one of the recent "clipboard/" responses, you'll see the URL - go to the highlighted URL:

Screenshot 2023-03-16 at 3 39 27 PM

You will see that the "Copying..." toast stays until you access that URL, which is how we signal the completion of our (faked) async operation.

Deadline

None

Other information

Private ref - MNG-3572

@openedx-webhooks openedx-webhooks added open-source-contribution PR author is not from Axim or 2U core committer labels Mar 10, 2023
@openedx-webhooks
Copy link

Thanks for the pull request, @bradenmacdonald!

As a core committer in this repo, you can merge this once the pull request is approved per the core committer reviewer requirements and according to the agreement with your edX Champion.

@bradenmacdonald
Copy link
Contributor Author

@Agrendalath @ormsbee This isn't quite working yet but if you want to see how it's shaping up, feel free :)
Otherwise I'll let you know when it's all ready for review.

@bradenmacdonald bradenmacdonald added FC Relates to an Axim Funded Contribution project and removed open-source-contribution PR author is not from Axim or 2U labels Mar 10, 2023
@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Mar 10, 2023
@bradenmacdonald
Copy link
Contributor Author

@Agrendalath @ormsbee This is ready for review now.

I would still like to consolidate the two similar XBlockSerializer implementations in olx_rest_api and this new content_staging, but I think I'll do that in a follow-up PR to make it easier to review. If you prefer I do that before merging, let me know.

Copy link
Member

@Agrendalath Agrendalath left a comment

Choose a reason for hiding this comment

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

The Swagger is broken, but everything else works perfectly.

Copy link
Member

@Agrendalath Agrendalath left a comment

Choose a reason for hiding this comment

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

👍

  • I tested this: tested the functionality on the devstack
  • I read through the code
  • I checked for accessibility issues: n/a
  • Includes documentation
  • I made sure any change in configuration variables is reflected in the corresponding client's configuration-secure repository: n/a


def get_source_context_title(self, obj):
return obj.get_source_context_title()
get_source_context_title.short_description = 'Source Context Title'
Copy link
Member

Choose a reason for hiding this comment

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

Nit: should we mark this (and 'Content') as translatable?

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 don't think it's worth translator's efforts to translate the Django admin, especially since the only use case I see here is for developers. I don't think operators would ever use this Django admin, and we may even remove it once the feature is stable.

""" Admin config for UserClipboard """
list_display = ('user', 'content_link', 'source_usage_key', 'get_source_context_title')
search_fields = ('user__username', 'source_usage_key', 'content__display_name')
readonly_fields = ('source_context_key', 'get_source_context_title')
Copy link
Member

Choose a reason for hiding this comment

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

Nit: is it intended that User, Content, and Source usage key are editable in the admin detail view?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

not really; I didn't worry too much about this. I can make everything read only before I merge this.

@bradenmacdonald
Copy link
Contributor Author

@ormsbee Can you give this a final review?

Copy link
Contributor

@ormsbee ormsbee left a comment

Choose a reason for hiding this comment

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

Some immediate questions on deletion and longer term mulling over how much StagedContent has to understand.



@shared_task(base=LoggedTask)
def delete_expired_staged_content():
Copy link
Contributor

Choose a reason for hiding this comment

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

We've had some painful operational experiences with select_for_update, to the point where I'm pretty skittish about code using it, even where skip_locked=True.

Do we really need this at the moment? The code already disconnects the StagedContent from the UserClipboard when there's another copy. Can't we spin off a task to delete that old entry at that time? (I guess we'd need to be careful about transactions to make sure there's no weird race condition, but still...)

It looks like the current philosophy of cleanup is to centralize it at the StagedContent model, and to cascade outwards to things that build on top of it like UserClipboard. But given the differing policies that might come up between things like UserClipboard and partial import, I think it might be easier to trigger the deletion from the other direction–i.e. "disconnect your extended thing (fast operation), queue task to delete StagedContent (potentially slow)"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK, I'll refactor along those lines. Makes total sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ormsbee OK, I've made that change. Can you give this another look?

Comment on lines 35 to 37
class Purpose(models.TextChoices):
""" The purpose of this staged content. """
CLIPBOARD = "clipboard", _("Clipboard")
Copy link
Contributor

Choose a reason for hiding this comment

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

Does StagedContent really need this? StagedContent is otherwise a lower-level model that UserClipboard makes use of. It seems odd for it to have this kind of knowledge.

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 know, but otherwise it's hard to tell what the StagedContent was for. UserClipboard has a OneToOne relationship to User, which means that when a user copies something new, there is no "old" UserClipboard entry. So when a user copies something then copies something else, you end up with"stranded" StagedContent entries that aren't pointed to by UserClipboard and so without this field, there is no information about their purpose. It's impossible to tell from the database if they were used for clipboard or for some other thing (partial import, template, ... ?)

Now, if I make it so that UserClipboard always immediately deletes its old StagedContent rows, that would solve the problem, but that makes copying slower unless it's done asynchronously. And if it's done asynchronously, it might fail and then you'd get "stranded" StagedContent rows with no information about what they were used for. At least, that was my thought process. Let me know if you think that's not important.

Another option is to leave the purpose field but don't make it an enum, so that it can be flexibly used without knowing all the possible apps that will use it. Or I can just delete it any rely on the fact that for now all stagedcontent is clipboard content?

Copy link
Contributor

Choose a reason for hiding this comment

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

Another option is to leave the purpose field but don't make it an enum, so that it can be flexibly used without knowing all the possible apps that will use it. Or I can just delete it any rely on the fact that for now all stagedcontent is clipboard content?

I do think we should kick off the async deletion of the old StagedContent as soon as a new thing has started copying, but I take the point that deletion could always fail. I have a mild preference to just leave it a freeform field then (ditching the Enum as you suggest here). But I don't feel strongly about it.

Comment on lines +52 to +53
# The user that created and owns this staged content. Only this user can read it.
user = models.ForeignKey(User, null=False, 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.

Not something for this PR, but I'm starting to wonder if StagedContent might be better as a lower-level primitive that doesn't even necessarily have a REST API or user information–because it's not exposed directly and relies on higher level constructs for things like permissions. For instance, I can imagine things like Unit templates (e.g. "Case Study": html, video, question) that are StagedContent but don't belong to a particular user.

In that situation StagedContent isn't responsible for figuring how who owns content or how it should be used. But only storing a consistent little set of content + assets together.

Copy link
Contributor

@ormsbee ormsbee left a comment

Choose a reason for hiding this comment

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

Minor question and suggestion w.r.t. transaction handling.

@@ -0,0 +1,22 @@
"""
Copy link
Contributor

Choose a reason for hiding this comment

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

I missed this the first time around, but please rename this to handlers.py. I personally prefer signal_handlers.py, but it handlers.py is the common convention in edx-platform and OEP-49.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Whoops, I don't actually want the signal handlers there anymore anyways since I'm now dispatching the cleanup for individual rows at copy time as you suggested. Fixed by deleting the whole file.

e.g. "video" if a video is staged, or "vertical" for a unit.
"""),
)
olx = models.TextField(null=False, blank=False)
Copy link
Contributor

Choose a reason for hiding this comment

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

No action required, but just for future consideration: Can we imagine using the clipboard to do a straight copy of course assets without any specific XBlock OLX?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Predicting future requirements is a risky business :p I can imagine it, sure, but I have no idea if we'll think it's a good idea at some point in the future or not. I can also imagine StagedContent being a mutable work area instead of a read-only staging area, but I'm also unsure if that will be a good idea or not.

clipboard or to POST some content to the clipboard.
"""

@atomic
Copy link
Contributor

Choose a reason for hiding this comment

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

Is anything actually being written in this request?

Copy link
Contributor Author

@bradenmacdonald bradenmacdonald Apr 11, 2023

Choose a reason for hiding this comment

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

Whoops, no. Probably copy-pasta.

})


@view_auth_classes(is_authenticated=True)
Copy link
Contributor

Choose a reason for hiding this comment

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

You might want to decorate this to prevent Django from ever enclosing this in a view-wide implicit transaction (which could later cause concurrency issues with cleanup):

@method_decorator(transaction.non_atomic_requests, name='dispatch')

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, done.

@bradenmacdonald
Copy link
Contributor Author

@ormsbee @Agrendalath Is this good to merge?

Copy link
Member

@Agrendalath Agrendalath left a comment

Choose a reason for hiding this comment

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

I didn't re-test it, but the code changes look good.

@bradenmacdonald bradenmacdonald merged commit 2dc79bc into openedx:master Apr 14, 2023
@bradenmacdonald bradenmacdonald deleted the copy-olx-backend-app branch April 14, 2023 18:41
@openedx-webhooks
Copy link

@bradenmacdonald 🎉 Your pull request was merged! Please take a moment to answer a two question survey so we can improve your experience in the future.

@edx-pipeline-bot
Copy link
Contributor

EdX Release Notice: This PR has been deployed to the staging environment in preparation for a release to production.

@edx-pipeline-bot
Copy link
Contributor

EdX Release Notice: This PR has been deployed to the production environment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core committer FC Relates to an Axim Funded Contribution project open-source-contribution PR author is not from Axim or 2U

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

5 participants