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
********************

Added
=====

* Add PoF role and permissions for the advanced course settings section

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"

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
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",
)
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"

# 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.

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"

# 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)
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