feat: support role assignment for scopes that don't exist yet - #369
feat: support role assignment for scopes that don't exist yet#369efortish wants to merge 6 commits into
Conversation
|
Thanks for the pull request, @efortish! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
mariajgrimaldi
left a comment
There was a problem hiding this comment.
Can we deploy this into our sandbox that already has data to test? Thanks!
| CourseScope.objects.filter( | ||
| external_key=str(instance.id), course_overview__isnull=True | ||
| ).update(course_overview=instance) |
There was a problem hiding this comment.
Don't we have a method for this so we don't use the model directly?
| ContentLibraryScope.objects.filter( | ||
| external_key=str(instance.library_key), content_library__isnull=True | ||
| ).update(content_library=instance) |
There was a problem hiding this comment.
Same question here: don't we have a method for this so we don't use the model directly?
| # This is the only way to find a scope back again when its FK to that object is still null | ||
| # (see get_or_create_for_external_key() in openedx_authz/models/scopes.py) so it can be | ||
| # linked up once the object is created (see openedx_authz/handlers.py backfill receivers). | ||
| external_key = models.CharField(max_length=255, null=True, blank=True, unique=True, db_index=True) |
There was a problem hiding this comment.
Should it be indexed? Is it needed? Also, should it be unique?
| content_library = ContentLibrary.objects.get_by_key(library_key) | ||
| scope, _ = cls.objects.get_or_create(content_library=content_library) | ||
| try: | ||
| content_library = ContentLibrary.objects.get_by_key(library_key) |
There was a problem hiding this comment.
Don't we have a method for checking this?
| try: | ||
| course_overview = CourseOverview.get_from_id(course_key) | ||
| except CourseOverview.DoesNotExist: | ||
| # The course doesn't exist yet: key the row by its external_key so it can be | ||
| # found and linked up later by the backfill signal in openedx_authz/handlers.py. |
There was a problem hiding this comment.
Same question here about having a method to check this, if I remember correctly it's called exists()
- Drop redundant db_index on Scope.external_key (unique already indexes it). - Reuse ScopeData.get_object() instead of duplicating the DoesNotExist handling in CourseScope/ContentLibraryScope.get_or_create_for_external_key(). - Add CourseScope.link_pending_scope()/ContentLibraryScope.link_pending_scope() so the handlers.py signal receivers no longer touch the model querysets directly. Addresses review comments from mariajgrimaldi on openedx#369.
| @@ -98,25 +96,37 @@ def get_or_create_for_external_key(cls, scope) -> "ContentLibraryScope": | |||
| ContentLibraryScope: The Scope instance for the given ContentLibrary, | |||
There was a problem hiding this comment.
Please update this return description
| scope, _ = cls.objects.get_or_create(external_key=str(library_key)) | ||
| return scope | ||
| # found and linked up later by link_pending_scope(). | ||
| row, _ = cls.objects.get_or_create(external_key=scope.external_key) |
There was a problem hiding this comment.
I don't think row says much. I prefer the previous name, scope
| if course_overview is None: | ||
| # The course doesn't exist yet: key the row by its external_key so it can be | ||
| # found and linked up later by link_pending_scope(). | ||
| row, _ = cls.objects.get_or_create(external_key=scope.external_key) |
| @@ -84,6 +82,12 @@ class ContentLibraryScope(Scope): | |||
| def get_or_create_for_external_key(cls, scope) -> "ContentLibraryScope": | |||
There was a problem hiding this comment.
The code in get_or_create_for_external_key and link_pending_scope is very similar to both clases. Do you think we can reduce the code duplication in any way?
…LibraryScope get_or_create_for_external_key() and link_pending_scope() were nearly identical between the two subclasses, differing only in the FK field name and how to compute external_key from the linked object. Move both methods to the base Scope class, parameterized by a LINKED_OBJECT_FIELD class attribute and an external_key_for_object() hook each subclass implements. Also restores the "scope" variable name (was "row") and fixes the stale "or None if glob pattern" return docstring, which described ScopeManager's dispatch behavior, not this method's. Addresses review comments from BryanttV on openedx#369.
Every merged PR in this repo bumps __version__ and adds a CHANGELOG.rst entry (feat -> minor bump, per semver); this PR was missing both. Also update the "extending Scope" section of the architecture ADR (0005), which still described the old per-subclass get_or_create_for_external_key() contract instead of the current LINKED_OBJECT_FIELD/external_key_for_object() one.
- Drop redundant db_index on Scope.external_key (unique already indexes it). - Reuse ScopeData.get_object() instead of duplicating the DoesNotExist handling in CourseScope/ContentLibraryScope.get_or_create_for_external_key(). - Add CourseScope.link_pending_scope()/ContentLibraryScope.link_pending_scope() so the handlers.py signal receivers no longer touch the model querysets directly. Addresses review comments from mariajgrimaldi on openedx#369.
…LibraryScope get_or_create_for_external_key() and link_pending_scope() were nearly identical between the two subclasses, differing only in the FK field name and how to compute external_key from the linked object. Move both methods to the base Scope class, parameterized by a LINKED_OBJECT_FIELD class attribute and an external_key_for_object() hook each subclass implements. Also restores the "scope" variable name (was "row") and fixes the stale "or None if glob pattern" return docstring, which described ScopeManager's dispatch behavior, not this method's. Addresses review comments from BryanttV on openedx#369.
Every merged PR in this repo bumps __version__ and adds a CHANGELOG.rst entry (feat -> minor bump, per semver); this PR was missing both. Also update the "extending Scope" section of the architecture ADR (0005), which still described the old per-subclass get_or_create_for_external_key() contract instead of the current LINKED_OBJECT_FIELD/external_key_for_object() one.
The backfill_course_scope/backfill_content_library_scope post_save receivers ran unconditionally on every CourseOverview/ContentLibrary save, including unrelated fixtures elsewhere in the suite. Wrap them in a try/except plus their own savepoint (transaction.atomic()) so a failure to link a pending scope never breaks the caller's save() or poisons its transaction. Also fix the ContentLibrary test stub's get_by_key(): it only set locator, leaving org/slug unset, so library_key crashed with AttributeError as soon as anything (now including ScopeData.get_object()'s canonical-key check) touched it. It now populates org/slug like the real manager does. Fixes the 6 test failures from CI on this PR. refactor: deduplicate pending-scope logic between CourseScope/ContentLibraryScope get_or_create_for_external_key() and link_pending_scope() were nearly identical between the two subclasses, differing only in the FK field name and how to compute external_key from the linked object. Move both methods to the base Scope class, parameterized by a LINKED_OBJECT_FIELD class attribute and an external_key_for_object() hook each subclass implements. Also restores the "scope" variable name (was "row") and fixes the stale "or None if glob pattern" return docstring, which described ScopeManager's dispatch behavior, not this method's. Addresses review comments from BryanttV on openedx#369.
Every merged PR in this repo bumps __version__ and adds a CHANGELOG.rst entry (feat -> minor bump, per semver); this PR was missing both.
Description
During course rerun,
edx-platformneeds to grant a user the instructor/staff role on the destination course key before that course'sCourseOverviewexists yet (the clone happens after role assignment would ideally occur). Todayopenedx-authzcrashes in that case, soedx-platform(PR #38840) works around it with authz-specific branching that defers role assignment until after the course is cloned, plus a fallback check elsewhere in the meantime.The actual crash:
CourseScope.get_or_create_for_external_key()/ContentLibraryScope.get_or_create_for_external_key()callCourseOverview.get_from_id()/ContentLibrary.objects.get_by_key(), both of which raiseDoesNotExistwhen the course/library isn't created yet.This PR makes role assignment tolerant of that: the
Scoperow is created with its object FK leftNone, and gets linked up automatically once the realCourseOverview/ContentLibraryis saved.Out of scope
Reverting the
edx-platformPR #38840 workaround is explicit follow-up called out in the issue thread, not part of this change. The REST API'sscope.exists()validation (rest_api/v1/serializers.py) is untouched — it's an intentional, separate guard for untrusted HTTP callers; this fix only affects the internal Python API path used directly by trusted platform code (e.g.student/roles.py'sauthz_add_role).Solves #352