Skip to content

Commit d7da43b

Browse files
committed
fixup! feat: add PoC permission and role
1 parent f004d5d commit d7da43b

5 files changed

Lines changed: 275 additions & 106 deletions

File tree

openedx_authz/api/data.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99

1010
from attrs import define
1111
from opaque_keys import InvalidKeyError
12+
from opaque_keys.edx.keys import CourseKey
1213
from opaque_keys.edx.locator import LibraryLocatorV2
1314

15+
from openedx_authz.tests.stubs.models import CourseOverview
16+
1417
try:
1518
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
1619
except ImportError:
@@ -462,6 +465,108 @@ def __repr__(self):
462465
return self.namespaced_key
463466

464467

468+
@define
469+
class CourseOverviewData(ScopeData):
470+
"""A course scope for authorization in the Open edX platform.
471+
472+
Courses uses the CourseKey format for identification.
473+
474+
Attributes:
475+
NAMESPACE: 'course' for course scopes.
476+
external_key: The course identifier (e.g., 'course-v1:TestOrg+TestCourse+2024_T1').
477+
Must be a valid CourseKey format.
478+
namespaced_key: The course identifier with namespace (e.g., 'course^course-v1:TestOrg+TestCourse+2024_T1').
479+
course_id: Property alias for external_key.
480+
481+
Examples:
482+
>>> course = CourseOverviewData(external_key='course-v1:TestOrg+TestCourse+2024_T1')
483+
>>> course.namespaced_key
484+
'course^course-v1:TestOrg+TestCourse+2024_T1'
485+
>>> course.course_id
486+
'course-v1:TestOrg+TestCourse+2024_T1'
487+
488+
"""
489+
490+
NAMESPACE: ClassVar[str] = "course"
491+
492+
@property
493+
def course_id(self) -> str:
494+
"""The course identifier as used in Open edX (e.g., 'course-v1:TestOrg+TestCourse+2024_T1').
495+
496+
This is an alias for external_key that represents the course ID without the namespace prefix.
497+
498+
Returns:
499+
str: The course identifier without namespace.
500+
"""
501+
return self.external_key
502+
503+
@property
504+
def course_key(self) -> CourseKey:
505+
"""The CourseKey object for the course.
506+
507+
Returns:
508+
CourseKey: The course key object.
509+
"""
510+
return CourseKey.from_string(self.course_id)
511+
512+
@classmethod
513+
def validate_external_key(cls, external_key: str) -> bool:
514+
"""Validate the external_key format for CourseOverviewData.
515+
516+
Args:
517+
external_key: The external key to validate.
518+
519+
Returns:
520+
bool: True if valid, False otherwise.
521+
"""
522+
try:
523+
CourseKey.from_string(external_key)
524+
return True
525+
except InvalidKeyError:
526+
return False
527+
528+
def get_object(self) -> CourseOverview | None:
529+
"""Retrieve the CourseOverview instance associated with this scope.
530+
531+
This method converts the course_id to a CourseKey and queries the
532+
database to fetch the corresponding CourseOverview object.
533+
534+
Returns:
535+
CourseOverview | None: The CourseOverview instance if found in the database,
536+
or None if the course does not exist or has an invalid key format.
537+
538+
Examples:
539+
>>> course_scope = CourseOverviewData(external_key='course-v1:TestOrg+TestCourse+2024_T1')
540+
>>> course_obj = course_scope.get_object() # CourseOverview object
541+
"""
542+
try:
543+
course_obj = CourseOverview.get_from_id(self.course_key)
544+
# Validate canonical key: get_by_key is case-insensitive, but we require exact match
545+
# This ensures authorization uses canonical course IDs consistently
546+
if course_obj.id != self.course_key:
547+
raise CourseOverview.DoesNotExist
548+
except (InvalidKeyError, CourseOverview.DoesNotExist):
549+
return None
550+
551+
return course_obj
552+
553+
def exists(self) -> bool:
554+
"""Check if the course overview exists.
555+
556+
Returns:
557+
bool: True if the course overview exists, False otherwise.
558+
"""
559+
return self.get_object() is not None
560+
561+
def __str__(self):
562+
"""Human readable string representation of the course overview."""
563+
return self.course_id
564+
565+
def __repr__(self):
566+
"""Developer friendly string representation of the course overview."""
567+
return self.namespaced_key
568+
569+
465570
class SubjectMeta(type):
466571
"""Metaclass for SubjectData to handle dynamic subclass instantiation based on namespace."""
467572

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Generated by Django 4.2.24 on 2026-02-06 17:19
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("stubs", "__first__"),
11+
("openedx_authz", "0006_migrate_legacy_permissions"),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name="CourseScope",
17+
fields=[
18+
(
19+
"scope_ptr",
20+
models.OneToOneField(
21+
auto_created=True,
22+
on_delete=django.db.models.deletion.CASCADE,
23+
parent_link=True,
24+
primary_key=True,
25+
serialize=False,
26+
to="openedx_authz.scope",
27+
),
28+
),
29+
(
30+
"course_overview",
31+
models.ForeignKey(
32+
blank=True,
33+
null=True,
34+
on_delete=django.db.models.deletion.CASCADE,
35+
related_name="authz_scopes",
36+
to=settings.OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL,
37+
),
38+
),
39+
],
40+
options={
41+
"abstract": False,
42+
},
43+
bases=("openedx_authz.scope",),
44+
),
45+
]

openedx_authz/tests/api/test_roles.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,19 +61,6 @@ def _mock_get_or_create_subject(subject_data):
6161
return subject
6262

6363

64-
# Apply patches at module level using the new manager method
65-
_scope_patcher = patch(
66-
"openedx_authz.models.ScopeManager.get_or_create_for_external_key",
67-
side_effect=_mock_get_or_create_scope,
68-
)
69-
_subject_patcher = patch(
70-
"openedx_authz.models.SubjectManager.get_or_create_for_external_key",
71-
side_effect=_mock_get_or_create_subject,
72-
)
73-
_scope_patcher.start()
74-
_subject_patcher.start()
75-
76-
7764
class BaseRolesTestCase(TestCase):
7865
"""Base test case with helper methods for roles testing.
7966
@@ -134,6 +121,26 @@ def setUpClass(cls):
134121
to add their specific role assignments by calling _assign_roles_to_users.
135122
"""
136123
super().setUpClass()
124+
125+
# Apply patches here so they only affect this test
126+
# and don't interfere with other tests using the real
127+
# get_or_create_for_external_key implementation.
128+
cls._scope_patcher = patch(
129+
"openedx_authz.models.ScopeManager.get_or_create_for_external_key",
130+
side_effect=_mock_get_or_create_scope,
131+
)
132+
cls._subject_patcher = patch(
133+
"openedx_authz.models.SubjectManager.get_or_create_for_external_key",
134+
side_effect=_mock_get_or_create_subject,
135+
)
136+
137+
cls._scope_patcher.start()
138+
cls._subject_patcher.start()
139+
140+
AuthzEnforcer.get_enforcer().stop_auto_load_policy()
141+
AuthzEnforcer.get_enforcer().enable_auto_save(True)
142+
cls._seed_database_with_policies()
143+
137144
AuthzEnforcer.get_enforcer().stop_auto_load_policy()
138145
# Enable auto-save to ensure policies are saved to the database
139146
# This is necessary because the tests are not using auto-load policy
@@ -150,6 +157,15 @@ def tearDown(self):
150157
super().tearDown()
151158
AuthzEnforcer.get_enforcer().clear_policy() # Clear policies after each test to ensure isolation
152159

160+
@classmethod
161+
def tearDownClass(cls):
162+
# Stop patches cleanly
163+
# This ensures that if any test fails, the patches will still be stopped properly,
164+
# preventing side effects on other tests.
165+
cls._scope_patcher.stop()
166+
cls._subject_patcher.stop()
167+
super().tearDownClass()
168+
153169

154170
class RolesTestSetupMixin(BaseRolesTestCase):
155171
"""Test case with comprehensive role assignments for general roles testing."""

openedx_authz/tests/stubs/models.py

Lines changed: 22 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.contrib.auth.models import Group
99
from django.db import models
1010
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
11+
from opaque_keys.edx.keys import CourseKey
1112
from opaque_keys.edx.locator import LibraryLocatorV2
1213

1314

@@ -94,103 +95,33 @@ class CourseOverview(models.Model):
9495
"""
9596
Model for storing and caching basic information about a course.
9697
97-
This model contains basic course metadata such as an ID, display name,
98-
image URL, and any other information that would be necessary to display
99-
a course as part of:
100-
user dashboard (enrolled courses)
101-
course catalog (courses to enroll in)
102-
course about (meta data about the course)
98+
This model contains basic course metadata such as an ID, display name, and organization.
99+
It is used to link CourseScope instances to actual courses in the system.
103100
104101
.. no_pii:
105102
"""
106103

107-
class Meta:
108-
app_label = "course_overviews"
109-
110-
# IMPORTANT: Bump this whenever you modify this model and/or add a migration.
111-
VERSION = 19
112-
113-
# Cache entry versioning.
114-
version = models.IntegerField()
115-
116104
# Course identification
117105
id = CourseKeyField(db_index=True, primary_key=True, max_length=255)
118106
_location = UsageKeyField(max_length=255)
119107
org = models.TextField(max_length=255, default="outdated_entry")
120108
display_name = models.TextField(null=True)
121-
display_number_with_default = models.TextField()
122-
display_org_with_default = models.TextField()
123-
124-
start = models.DateTimeField(null=True)
125-
end = models.DateTimeField(null=True)
126-
127-
# These are deprecated and unused, but cannot be dropped via simple migration due to the size of the downstream
128-
# history table. See DENG-19 for details.
129-
# Please use start and end above for these values.
130-
start_date = models.DateTimeField(null=True)
131-
end_date = models.DateTimeField(null=True)
132-
133-
advertised_start = models.TextField(null=True)
134-
announcement = models.DateTimeField(null=True)
135-
136-
# URLs
137-
# Not allowing null per django convention; not sure why many TextFields in this model do allow null
138-
banner_image_url = models.TextField()
139-
course_image_url = models.TextField()
140-
social_sharing_url = models.TextField(null=True)
141-
end_of_course_survey_url = models.TextField(null=True)
142-
143-
# Certification data
144-
certificates_display_behavior = models.TextField(null=True)
145-
certificates_show_before_end = models.BooleanField(default=False)
146-
cert_html_view_enabled = models.BooleanField(default=False)
147-
has_any_active_web_certificate = models.BooleanField(default=False)
148-
cert_name_short = models.TextField()
149-
cert_name_long = models.TextField()
150-
certificate_available_date = models.DateTimeField(default=None, null=True)
151-
152-
# Grading
153-
lowest_passing_grade = models.DecimalField(max_digits=5, decimal_places=2, null=True)
154-
155-
# Access parameters
156-
days_early_for_beta = models.FloatField(null=True)
157-
mobile_available = models.BooleanField(default=False)
158-
visible_to_staff_only = models.BooleanField(default=False)
159-
_pre_requisite_courses_json = models.TextField() # JSON representation of list of CourseKey strings
160-
161-
# Enrollment details
162-
enrollment_start = models.DateTimeField(null=True)
163-
enrollment_end = models.DateTimeField(null=True)
164-
enrollment_domain = models.TextField(null=True)
165-
invitation_only = models.BooleanField(default=False)
166-
max_student_enrollments_allowed = models.IntegerField(null=True)
167-
168-
# Catalog information
169-
catalog_visibility = models.TextField(null=True)
170-
short_description = models.TextField(null=True)
171-
course_video_url = models.TextField(null=True)
172-
effort = models.TextField(null=True)
173-
self_paced = models.BooleanField(default=False)
174-
marketing_url = models.TextField(null=True)
175-
eligible_for_financial_aid = models.BooleanField(default=True)
176-
177-
# Course highlight info, used to guide course update emails
178-
has_highlights = models.BooleanField(null=True, default=None) # if None, you have to look up the answer yourself
179-
180-
# Proctoring
181-
enable_proctored_exams = models.BooleanField(default=False)
182-
proctoring_provider = models.TextField(null=True)
183-
proctoring_escalation_email = models.TextField(null=True)
184-
allow_proctoring_opt_out = models.BooleanField(default=False)
185-
186-
# Entrance Exam information
187-
entrance_exam_enabled = models.BooleanField(default=False)
188-
entrance_exam_id = models.CharField(max_length=255, blank=True)
189-
entrance_exam_minimum_score_pct = models.FloatField(default=0.65)
190-
191-
# Open Response Assessment configuration
192-
force_on_flexible_peer_openassessments = models.BooleanField(default=False)
193-
194-
external_id = models.CharField(max_length=128, null=True, blank=True)
195-
196-
language = models.TextField(null=True)
109+
110+
@classmethod
111+
def get_from_id(cls, course_key):
112+
"""Get a CourseOverview by its course key.
113+
114+
Args:
115+
course_key: The course key to look up.
116+
117+
Returns:
118+
CourseOverview: The course overview instance.
119+
"""
120+
if course_key is None:
121+
raise ValueError("course_key must not be None")
122+
try:
123+
key = str(CourseKey.from_string(str(course_key)))
124+
except Exception: # pylint: disable=broad-exception-caught
125+
key = str(course_key)
126+
obj, _ = cls.objects.get_or_create(id=key)
127+
return obj

0 commit comments

Comments
 (0)