Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ Unreleased

*


0.20.1 - 2026-02-05
Comment thread
dwong2708 marked this conversation as resolved.
Outdated
********************

Added
=====

* Add PoF role and permissions for the advanced course settings section
Comment thread
dwong2708 marked this conversation as resolved.
Outdated

0.20.0 - 2025-11-27
********************

Expand Down
2 changes: 1 addition & 1 deletion openedx_authz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

import os

__version__ = "0.20.0"
__version__ = "0.20.1"
Comment thread
dwong2708 marked this conversation as resolved.
Outdated

ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
9 changes: 9 additions & 0 deletions openedx_authz/constants/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,12 @@
action=ActionData(external_key=f"{CONTENT_LIBRARIES_NAMESPACE}.delete_library_collection"),
effect="allow",
)

# Course Permissions

COURSES_NAMESPACE = "courses"

MANAGE_ADVANCED_SETTINGS = PermissionData(
action=ActionData(external_key=f"{COURSES_NAMESPACE}.manage_advanced_settings"),
effect="allow",
)
10 changes: 10 additions & 0 deletions openedx_authz/constants/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,13 @@
LIBRARY_AUTHOR = RoleData(external_key="library_author", permissions=LIBRARY_AUTHOR_PERMISSIONS)
LIBRARY_CONTRIBUTOR = RoleData(external_key="library_contributor", permissions=LIBRARY_CONTRIBUTOR_PERMISSIONS)
LIBRARY_USER = RoleData(external_key="library_user", permissions=LIBRARY_USER_PERMISSIONS)


# Course Roles and Permissions


COURSE_STAFF_PERMISSIONS = [
permissions.MANAGE_ADVANCED_SETTINGS,
]

COURSE_STAFF = RoleData(external_key="course_staff", permissions=COURSE_STAFF_PERMISSIONS)
6 changes: 6 additions & 0 deletions openedx_authz/engine/config/authz.policy
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,9 @@ g2, act^content_libraries.manage_library_team, act^content_libraries.view_librar
g2, act^content_libraries.delete_library_collection, act^content_libraries.edit_library_collection
g2, act^content_libraries.create_library_collection, act^content_libraries.edit_library_collection
g2, act^content_libraries.edit_library_collection, act^content_libraries.view_library


# Course Policies

# Course Staff Permissions
p, role^course_staff, act^courses.manage_advanced_settings, course^*, allow
Comment thread
dwong2708 marked this conversation as resolved.
Outdated
63 changes: 63 additions & 0 deletions openedx_authz/models/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.apps import apps
from django.conf import settings
from django.db import models
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryLocatorV2

from openedx_authz.models.core import Scope
Expand All @@ -31,7 +32,26 @@ def get_content_library_model():
return None


def get_course_overview_model():
"""Return the CourseOverview model class specified by settings.

The setting `OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL` should be an
app_label.ModelName string (e.g. 'content.CourseOverview').
"""
COURSE_OVERVIEW_MODEL = getattr(
settings,
"OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL",
"content.CourseOverview",
Comment thread
dwong2708 marked this conversation as resolved.
Outdated
)
try:
app_label, model_name = COURSE_OVERVIEW_MODEL.split(".")
return apps.get_model(app_label, model_name, require_ready=False)
except LookupError:
return None


ContentLibrary = get_content_library_model()
CourseOverview = get_course_overview_model()


class ContentLibraryScope(Scope):
Expand Down Expand Up @@ -75,3 +95,46 @@ def get_or_create_for_external_key(cls, scope):
content_library = ContentLibrary.objects.get_by_key(library_key)
scope, _ = cls.objects.get_or_create(content_library=content_library)
return scope


class CourseScope(Scope):
"""Scope representing a course in the authorization system.

.. no_pii:
"""

NAMESPACE = "course"

# Link to the actual course, if applicable. In other cases, this could be null.
# Piggybacking on the existing CourseOverview model to keep the ExtendedCasbinRule up to date
# by deleting the Scope, and thus the ExtendedCasbinRule, when the CourseOverview is deleted.
#
# When content IS available, the on_delete=CASCADE will still work at the
# application level through Django's signal handlers.
# Use a string reference to the external app's model so Django won't try
# to import it at model import time. The migration already records the
# dependency on `content` when the app is present.
course_overview = models.ForeignKey(
settings.OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="authz_scopes",
swappable=True,
)

@classmethod
def get_or_create_for_external_key(cls, scope):
"""Get or create a CourseScope for the given external key.

Args:
scope: ScopeData object with an external_key attribute containing
a CourseKey string.

Returns:
CourseScope: The Scope instance for the given CourseOverview
"""
course_key = CourseKey.from_string(scope.external_key)
course_overview = CourseOverview.get_from_id(course_key)
scope, _ = cls.objects.get_or_create(course_overview=course_overview)
return scope
4 changes: 4 additions & 0 deletions openedx_authz/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ def plugin_settings(settings):
if not hasattr(settings, "OPENEDX_AUTHZ_CONTENT_LIBRARY_MODEL"):
settings.OPENEDX_AUTHZ_CONTENT_LIBRARY_MODEL = "content_libraries.ContentLibrary"

# Set default CourseOverview model for swappable dependency
if not hasattr(settings, "OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL"):
settings.OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL = "content.CourseOverview"
Comment thread
dwong2708 marked this conversation as resolved.
Outdated

# Set default CASBIN_LOG_LEVEL if not already set.
# This setting defines the logging level for the Casbin enforcer.
if not hasattr(settings, "CASBIN_LOG_LEVEL"):
Expand Down
1 change: 1 addition & 0 deletions openedx_authz/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,4 @@ def plugin_settings(settings): # pylint: disable=unused-argument

# Use stub model for testing instead of the real content_libraries app
OPENEDX_AUTHZ_CONTENT_LIBRARY_MODEL = "stubs.ContentLibrary"
OPENEDX_AUTHZ_COURSE_OVERVIEW_MODEL = "stubs.CourseOverview"
107 changes: 107 additions & 0 deletions openedx_authz/tests/stubs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.conf import settings
from django.contrib.auth.models import Group
from django.db import models
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
from opaque_keys.edx.locator import LibraryLocatorV2


Expand Down Expand Up @@ -87,3 +88,109 @@ class ContentLibraryPermission(models.Model):
def __str__(self):
who = self.user.username if self.user else self.group.name
return f"ContentLibraryPermission ({self.access_level} for {who})"


class CourseOverview(models.Model):
"""
Model for storing and caching basic information about a course.
Comment thread
dwong2708 marked this conversation as resolved.
Outdated

This model contains basic course metadata such as an ID, display name,
image URL, and any other information that would be necessary to display
a course as part of:
user dashboard (enrolled courses)
course catalog (courses to enroll in)
course about (meta data about the course)

.. no_pii:
"""

class Meta:
app_label = "course_overviews"
Comment thread
dwong2708 marked this conversation as resolved.
Outdated

# IMPORTANT: Bump this whenever you modify this model and/or add a migration.
VERSION = 19

# Cache entry versioning.
version = models.IntegerField()

# Course identification
id = CourseKeyField(db_index=True, primary_key=True, max_length=255)
_location = UsageKeyField(max_length=255)
org = models.TextField(max_length=255, default="outdated_entry")
display_name = models.TextField(null=True)
display_number_with_default = models.TextField()
display_org_with_default = models.TextField()

start = models.DateTimeField(null=True)
end = models.DateTimeField(null=True)

# These are deprecated and unused, but cannot be dropped via simple migration due to the size of the downstream
# history table. See DENG-19 for details.
# Please use start and end above for these values.
start_date = models.DateTimeField(null=True)
end_date = models.DateTimeField(null=True)

advertised_start = models.TextField(null=True)
Comment thread
dwong2708 marked this conversation as resolved.
Outdated
announcement = models.DateTimeField(null=True)

# URLs
# Not allowing null per django convention; not sure why many TextFields in this model do allow null
banner_image_url = models.TextField()
course_image_url = models.TextField()
social_sharing_url = models.TextField(null=True)
end_of_course_survey_url = models.TextField(null=True)

# Certification data
certificates_display_behavior = models.TextField(null=True)
certificates_show_before_end = models.BooleanField(default=False)
cert_html_view_enabled = models.BooleanField(default=False)
has_any_active_web_certificate = models.BooleanField(default=False)
cert_name_short = models.TextField()
cert_name_long = models.TextField()
certificate_available_date = models.DateTimeField(default=None, null=True)

# Grading
lowest_passing_grade = models.DecimalField(max_digits=5, decimal_places=2, null=True)

# Access parameters
days_early_for_beta = models.FloatField(null=True)
mobile_available = models.BooleanField(default=False)
visible_to_staff_only = models.BooleanField(default=False)
_pre_requisite_courses_json = models.TextField() # JSON representation of list of CourseKey strings

# Enrollment details
enrollment_start = models.DateTimeField(null=True)
enrollment_end = models.DateTimeField(null=True)
enrollment_domain = models.TextField(null=True)
invitation_only = models.BooleanField(default=False)
max_student_enrollments_allowed = models.IntegerField(null=True)

# Catalog information
catalog_visibility = models.TextField(null=True)
short_description = models.TextField(null=True)
course_video_url = models.TextField(null=True)
effort = models.TextField(null=True)
self_paced = models.BooleanField(default=False)
marketing_url = models.TextField(null=True)
eligible_for_financial_aid = models.BooleanField(default=True)

# Course highlight info, used to guide course update emails
has_highlights = models.BooleanField(null=True, default=None) # if None, you have to look up the answer yourself

# Proctoring
enable_proctored_exams = models.BooleanField(default=False)
proctoring_provider = models.TextField(null=True)
proctoring_escalation_email = models.TextField(null=True)
allow_proctoring_opt_out = models.BooleanField(default=False)

# Entrance Exam information
entrance_exam_enabled = models.BooleanField(default=False)
entrance_exam_id = models.CharField(max_length=255, blank=True)
entrance_exam_minimum_score_pct = models.FloatField(default=0.65)

# Open Response Assessment configuration
force_on_flexible_peer_openassessments = models.BooleanField(default=False)

external_id = models.CharField(max_length=128, null=True, blank=True)

language = models.TextField(null=True)
2 changes: 1 addition & 1 deletion openedx_authz/tests/test_enforcer.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ def test_multi_scope_filtering(self):
org_count = len(global_enforcer.get_policy())

self.assertEqual(lib_count, expected_lib_count)
self.assertEqual(course_count, 6)
self.assertEqual(course_count, 7)
self.assertEqual(org_count, 3)

global_enforcer.clear_policy()
Expand Down
14 changes: 7 additions & 7 deletions openedx_authz/tests/test_engine_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@ def test_migrate_all_file_policies_to_database(self):

Expected Result:
- All policies from the file are loaded into the database
- The file contains 31 regular policies (p rules)
- The file contains 32 regular policies (p rules)
- Policy content matches expected file content
"""
expected_policy_count = 31
expected_policy_count = 32

migrate_policy_between_enforcers(self.source_enforcer, self.target_enforcer)
self.target_enforcer.load_policy()
Expand Down Expand Up @@ -216,7 +216,7 @@ def test_migrate_complete_file_contents(self):

self.assertEqual(
len(self.target_enforcer.get_policy()),
31,
32,
"Should have 31 regular policies from file",
)
self.assertEqual(
Expand Down Expand Up @@ -250,8 +250,8 @@ def test_migrate_partial_duplicates(self):
target_policies = self.target_enforcer.get_policy()
self.assertEqual(
len(target_policies),
31,
"Should have 31 policies total, with no duplicates",
32,
"Should have 32 policies total, with no duplicates",
)

duplicates = CasbinRule.objects.values("v0", "v1", "v2").annotate(total=Count("*")).filter(total__gt=1)
Expand Down Expand Up @@ -346,7 +346,7 @@ def test_migrate_preserves_existing_db_policies(self):
migrate_policy_between_enforcers(self.source_enforcer, self.target_enforcer)

target_policies = self.target_enforcer.get_policy()
self.assertEqual(len(target_policies), 32, "Should have 31 file policies + 1 custom policy")
self.assertEqual(len(target_policies), 33, "Should have 32 file policies + 1 custom policy")
self.assertIn(custom_policy, target_policies, "Custom database policy should be preserved")

def test_migrate_preserves_user_role_assignments_in_db(self):
Expand Down Expand Up @@ -382,4 +382,4 @@ def test_migrate_preserves_user_role_assignments_in_db(self):
)

target_policies = self.target_enforcer.get_policy()
self.assertEqual(len(target_policies), 31, "All 31 policies from file should be loaded")
self.assertEqual(len(target_policies), 32, "All 32 policies from file should be loaded")
Loading