Skip to content

Commit f03d486

Browse files
committed
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.
1 parent 0a57902 commit f03d486

2 files changed

Lines changed: 85 additions & 101 deletions

File tree

openedx_authz/models/core.py

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,22 +111,95 @@ class Scope(BaseRegistryModel):
111111
This model can be extended to represent different types of scopes,
112112
such as courses or content libraries.
113113
114-
Subclasses should define a NAMESPACE class attribute (e.g., 'lib' for content libraries)
115-
and implement get_or_create_for_external_key() classmethod.
114+
Subclasses should define a NAMESPACE class attribute (e.g., 'lib' for content libraries),
115+
a LINKED_OBJECT_FIELD naming their FK to the backing object (e.g. 'content_library'), and
116+
implement external_key_for_object().
116117
"""
117118

118119
objects = ScopeManager()
119120

121+
# Name of the subclass's FK field pointing at its backing object (e.g. "content_library",
122+
# "course_overview"), used by get_or_create_for_external_key()/link_pending_scope() below
123+
# to work generically across scope types.
124+
LINKED_OBJECT_FIELD: ClassVar[str] = None
125+
120126
# Canonical string form of the scope's key (e.g. a course-v1 course id), set on creation
121127
# regardless of whether the backing object (CourseOverview, ContentLibrary, ...) exists yet.
122128
# This is the only way to find a scope back again when its FK to that object is still null
123-
# (see get_or_create_for_external_key() in openedx_authz/models/scopes.py) so it can be
124-
# linked up once the object is created (see openedx_authz/handlers.py backfill receivers).
129+
# so it can be linked up once the object is created (see link_pending_scope() below and the
130+
# backfill signal receivers in openedx_authz/handlers.py).
125131
external_key = models.CharField(max_length=255, null=True, blank=True, unique=True)
126132

127133
class Meta:
128134
abstract = False
129135

136+
@classmethod
137+
def external_key_for_object(cls, linked_object) -> str:
138+
"""Return the canonical external_key string for one of this scope's backing objects.
139+
140+
Subclasses must override this to match how their ScopeData computes external_key
141+
(e.g. the course id, or the library key).
142+
143+
Args:
144+
linked_object: An instance of the model named by LINKED_OBJECT_FIELD.
145+
146+
Returns:
147+
str: The canonical external_key for that instance.
148+
"""
149+
raise NotImplementedError
150+
151+
@classmethod
152+
def get_or_create_for_external_key(cls, scope) -> "Scope":
153+
"""Get or create a scope instance for the given external key.
154+
155+
The backing object (CourseOverview, ContentLibrary, ...) need not exist yet (e.g.
156+
during a course rerun, the destination course id is known before the course is
157+
cloned): the scope is created with its LINKED_OBJECT_FIELD left ``None`` in that
158+
case, and gets linked up automatically once a matching object is saved (see
159+
link_pending_scope() below and the backfill signal receivers in
160+
openedx_authz/handlers.py).
161+
162+
Args:
163+
scope: ScopeData object with an external_key attribute.
164+
165+
Returns:
166+
Scope: The (possibly newly created) scope instance for this subclass.
167+
"""
168+
external_key = scope.external_key
169+
linked_object = scope.get_object()
170+
if linked_object is None:
171+
# The object doesn't exist yet: key the row by its external_key so it can be
172+
# found and linked up later by link_pending_scope().
173+
scope, _ = cls.objects.get_or_create(external_key=external_key)
174+
return scope
175+
176+
# Look up by the FK first (as before the external_key field existed) so scopes
177+
# created before this change are reused rather than duplicated.
178+
scope, created = cls.objects.get_or_create(
179+
**{cls.LINKED_OBJECT_FIELD: linked_object},
180+
defaults={"external_key": external_key},
181+
)
182+
if not created and not scope.external_key:
183+
scope.external_key = external_key
184+
scope.save(update_fields=["external_key"])
185+
return scope
186+
187+
@classmethod
188+
def link_pending_scope(cls, linked_object) -> None:
189+
"""Link a pending scope to the object that was just created for it.
190+
191+
Called from the relevant post_save signal receiver in openedx_authz/handlers.py
192+
once an object with a matching external_key shows up.
193+
194+
Args:
195+
linked_object: The model instance (CourseOverview, ContentLibrary, ...) that
196+
was just saved.
197+
"""
198+
cls.objects.filter(
199+
external_key=cls.external_key_for_object(linked_object),
200+
**{f"{cls.LINKED_OBJECT_FIELD}__isnull": True},
201+
).update(**{cls.LINKED_OBJECT_FIELD: linked_object})
202+
130203

131204
class Subject(BaseRegistryModel):
132205
"""Model representing a subject in the authorization system.

openedx_authz/models/scopes.py

Lines changed: 8 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class ContentLibraryScope(Scope):
5959
"""
6060

6161
NAMESPACE = "lib"
62+
LINKED_OBJECT_FIELD = "content_library"
6263

6364
# Link to the actual content library, if applicable. In other cases, this could be null.
6465
# Piggybacking on the existing ContentLibrary model to keep the ExtendedCasbinRule up to date
@@ -79,54 +80,9 @@ class ContentLibraryScope(Scope):
7980
)
8081

8182
@classmethod
82-
def get_or_create_for_external_key(cls, scope) -> "ContentLibraryScope":
83-
"""Get or create a ContentLibraryScope for the given external key.
84-
85-
The backing ContentLibrary need not exist yet (e.g. it may be created
86-
later as part of an in-progress operation): the scope is created with
87-
``content_library=None`` in that case and gets linked up automatically
88-
once a ContentLibrary with a matching key is saved (see link_pending_scope()
89-
and the backfill signal receiver in openedx_authz/handlers.py).
90-
91-
Args:
92-
scope: ScopeData object with an external_key attribute containing
93-
a LibraryLocatorV2-compatible string.
94-
95-
Returns:
96-
ContentLibraryScope: The Scope instance for the given ContentLibrary,
97-
or None if the scope is a glob pattern (contains wildcard).
98-
"""
99-
content_library = scope.get_object()
100-
if content_library is None:
101-
# The library doesn't exist yet: key the row by its external_key so it can be
102-
# found and linked up later by link_pending_scope().
103-
row, _ = cls.objects.get_or_create(external_key=scope.external_key)
104-
return row
105-
106-
# Look up by the FK first (as before this change) so scopes created before the
107-
# external_key field existed are reused rather than duplicated.
108-
row, created = cls.objects.get_or_create(
109-
content_library=content_library,
110-
defaults={"external_key": scope.external_key},
111-
)
112-
if not created and not row.external_key:
113-
row.external_key = scope.external_key
114-
row.save(update_fields=["external_key"])
115-
return row
116-
117-
@classmethod
118-
def link_pending_scope(cls, content_library) -> None:
119-
"""Link a pending ContentLibraryScope to the ContentLibrary that was just created for it.
120-
121-
Called from the ContentLibrary post_save signal receiver in openedx_authz/handlers.py
122-
once a library with a matching external_key shows up.
123-
124-
Args:
125-
content_library: The ContentLibrary instance that was just saved.
126-
"""
127-
cls.objects.filter(
128-
external_key=str(content_library.library_key), content_library__isnull=True
129-
).update(content_library=content_library)
83+
def external_key_for_object(cls, linked_object) -> str:
84+
"""Return the canonical external_key string for a ContentLibrary instance."""
85+
return str(linked_object.library_key)
13086

13187

13288
class CourseScope(Scope):
@@ -136,6 +92,7 @@ class CourseScope(Scope):
13692
"""
13793

13894
NAMESPACE = "course-v1"
95+
LINKED_OBJECT_FIELD = "course_overview"
13996

14097
# Link to the actual course, if applicable. In other cases, this could be null.
14198
# Piggybacking on the existing CourseOverview model to keep the ExtendedCasbinRule up to date
@@ -156,52 +113,6 @@ class CourseScope(Scope):
156113
)
157114

158115
@classmethod
159-
def get_or_create_for_external_key(cls, scope) -> "CourseScope":
160-
"""Get or create a CourseScope for the given external key.
161-
162-
The backing CourseOverview need not exist yet (e.g. during a course
163-
rerun, the destination course id is known before the course is
164-
cloned): the scope is created with ``course_overview=None`` in that
165-
case and gets linked up automatically once a CourseOverview with a
166-
matching id is saved (see link_pending_scope() and the backfill
167-
signal receiver in openedx_authz/handlers.py).
168-
169-
Args:
170-
scope: ScopeData object with an external_key attribute containing
171-
a CourseKey string.
172-
173-
Returns:
174-
CourseScope: The Scope instance for the given CourseOverview,
175-
or None if the scope is a glob pattern (contains wildcard).
176-
"""
177-
course_overview = scope.get_object()
178-
if course_overview is None:
179-
# The course doesn't exist yet: key the row by its external_key so it can be
180-
# found and linked up later by link_pending_scope().
181-
row, _ = cls.objects.get_or_create(external_key=scope.external_key)
182-
return row
183-
184-
# Look up by the FK first (as before this change) so scopes created before the
185-
# external_key field existed are reused rather than duplicated.
186-
row, created = cls.objects.get_or_create(
187-
course_overview=course_overview,
188-
defaults={"external_key": scope.external_key},
189-
)
190-
if not created and not row.external_key:
191-
row.external_key = scope.external_key
192-
row.save(update_fields=["external_key"])
193-
return row
194-
195-
@classmethod
196-
def link_pending_scope(cls, course_overview) -> None:
197-
"""Link a pending CourseScope to the CourseOverview that was just created for it.
198-
199-
Called from the CourseOverview post_save signal receiver in openedx_authz/handlers.py
200-
once a course with a matching external_key shows up.
201-
202-
Args:
203-
course_overview: The CourseOverview instance that was just saved.
204-
"""
205-
cls.objects.filter(
206-
external_key=str(course_overview.id), course_overview__isnull=True
207-
).update(course_overview=course_overview)
116+
def external_key_for_object(cls, linked_object) -> str:
117+
"""Return the canonical external_key string for a CourseOverview instance."""
118+
return str(linked_object.id)

0 commit comments

Comments
 (0)