Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@
path("admin/doc/", include("django.contrib.admindocs.urls")),
path("admin/", admin.site.urls),
path("tagging/rest_api/", include("openedx_tagging.urls")),
# Mirrors the prefix openedx-platform mounts CBE at; a consuming project picks its own.
path("api/", include("openedx_learning.urls")),
path('__debug__/', include('debug_toolbar.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
31 changes: 31 additions & 0 deletions src/openedx_learning/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,34 @@ Like ``openedx_content``, it is one Django app split into applets. Its first app

In the layering that ``.importlinter`` enforces, this app sits above ``openedx_catalog``,
``openedx_content`` and ``openedx_tagging``. It may build on any of them; none may import it.

Setting up a consuming project
------------------------------

A project that uses this app's REST API needs three things in place. Its URLconf includes
``openedx_learning.urls`` under whatever prefix it likes, and the CBE API is then served under
``<prefix>cbe/v1/``. Its ``INSTALLED_APPS`` lists ``rules.apps.AutodiscoverRulesConfig``, not
plain ``rules``, because only the autodiscovering config imports each app's ``rules.py`` and so
registers the permissions declared there. Its ``AUTHENTICATION_BACKENDS`` includes
``rules.permissions.ObjectPermissionBackend``, the backend that answers ``has_perm`` from those
registered permissions. If either ``rules`` setting is missing, the API answers 403 to every
caller who is not a superuser, and nothing in the logs says why. Superusers pass every
``has_perm`` check regardless, so testing as one hides the problem.

.. code-block:: python
# settings
INSTALLED_APPS = [
...,
"rules.apps.AutodiscoverRulesConfig",
"openedx_learning",
]
AUTHENTICATION_BACKENDS = [
...,
"rules.permissions.ObjectPermissionBackend",
]
# urls.py: serves the CBE API under api/cbe/v1/
urlpatterns = [
path("api/", include("openedx_learning.urls")),
]
18 changes: 17 additions & 1 deletion src/openedx_learning/applets/cbe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
from openedx_tagging.api import create_taxonomy
from openedx_tagging.models import Taxonomy

from .models import CompetencyTaxonomy
from .models import CompetencyRuleProfile, CompetencyTaxonomy

__all__ = [
"create_competency_taxonomy",
"get_competency_rule_profiles",
"is_competency_taxonomy",
"select_competency_taxonomies",
]
Expand Down Expand Up @@ -43,6 +44,21 @@ def create_competency_taxonomy( # pylint: disable=too-many-positional-arguments
return taxonomy


def get_competency_rule_profiles() -> QuerySet[CompetencyRuleProfile]:
"""
Return every live CompetencyRuleProfile, in ascending ``id`` order.

UNSTABLE: the rule profile family is incomplete, so the create, update, and archive entry
points still to come may change this function's shape without a deprecation cycle.

Archived profiles are left out: retirement is archive-only.

The ordering is part of the contract rather than a cosmetic detail: an unordered queryset
gives a paginating caller overlapping and skipped pages.
"""
return CompetencyRuleProfile.objects.filter(archived=False).order_by("id")


def is_competency_taxonomy(taxonomy: Taxonomy) -> bool:
"""
Return True if ``taxonomy`` is competency-enabled, i.e. has a CompetencyTaxonomy row.
Expand Down
Empty file.
20 changes: 20 additions & 0 deletions src/openedx_learning/applets/cbe/rest_api/paginators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""
Paginators for the CBE REST API.
These sit outside the versioned package, as openedx_tagging's do, because a page size is
version independent.
"""
from edx_rest_framework_extensions.paginators import DefaultPagination # type: ignore[import]


class CompetencyRuleProfilePagination(DefaultPagination):
"""
Page size for the competency rule profile collection.
Pinned here rather than inherited from the consuming project's DEFAULT_PAGINATION_CLASS, the
same way openedx_tagging pins TaxonomyPagination: this is a published library, so the page
size is part of its REST contract instead of varying by deployment.
"""

page_size = 100
max_page_size = 500
14 changes: 14 additions & 0 deletions src/openedx_learning/applets/cbe/rest_api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""
CBE API URLs.
"""

from django.urls import include, path

from .v1 import urls as v1_urls

# Namespaces the whole CBE API, so routes reverse as "cbe:<name>". Declared here rather than in
# v1/ because the namespace spans every version, and rather than on the app-root module because
# that one aggregates every applet.
app_name = "cbe"

urlpatterns = [path("v1/", include(v1_urls))]
Empty file.
19 changes: 19 additions & 0 deletions src/openedx_learning/applets/cbe/rest_api/v1/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""
Permissions for the CBE REST API, v1.
"""
from rest_framework.permissions import DjangoObjectPermissions


class CompetencyRuleProfilePermissions(DjangoObjectPermissions):
"""
Maps each REST API method to its corresponding CompetencyRuleProfile permission.
Only the read methods are mapped, so DRF answers a write attempt with 405 rather than
checking a permission that no write endpoint would honor anyway.
"""

perms_map = {
"GET": ["%(app_label)s.view_%(model_name)s"],
"OPTIONS": [],
"HEAD": ["%(app_label)s.view_%(model_name)s"],
}
56 changes: 56 additions & 0 deletions src/openedx_learning/applets/cbe/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""
Serializers for the CBE REST API, v1.
"""
from __future__ import annotations

from rest_framework import serializers

from ...models import CompetencyRuleProfile


class CompetencyRuleProfileSerializer(serializers.ModelSerializer):
"""
Read-only representation of a CompetencyRuleProfile.

UNSTABLE: the rule profile family is incomplete, so the create, update, and archive
endpoints still to come may change this shape without a deprecation cycle.

``rule_payload`` is emitted verbatim as stored. For ``Grade``, the only rule type
supported in this phase, that shape is ``{"op": ..., "value": ..., "scale": ...}``:
``op`` is one of ``gte``, ``lte``, or ``eq``, and ``value`` is a fraction between 0.0
and 1.0 inclusive. ``GradeRulePayload`` and ``validate_rule_payload`` in the
``rule_payloads`` module own that shape, so it is not renormalized here. ``scale`` is
always ``"percent"`` for now; it tells a client how to display ``value`` (``0.8`` means 80%).

``scope_code`` and the raw ``organization``, ``course``, and ``competency_taxonomy``
columns are left out: ``scope_type`` below is what a client can act on, while the
others are internal, existing only to enforce the one-profile-per-scope constraint
(:ref:`openedx-learning-adr-0002` Decision 3).
"""

scope_type = serializers.SerializerMethodField()

class Meta:
model = CompetencyRuleProfile
fields = ["id", "scope_type", "rule_type", "rule_payload", "archived"]
# scope_type is absent here because DRF refuses a field that is both declared above and
# named in read_only_fields; a SerializerMethodField is read-only in any case.
read_only_fields = ["id", "rule_type", "rule_payload", "archived"]

def get_scope_type(self, profile: CompetencyRuleProfile) -> str:
"""
Return which kind of scope ``profile`` applies to.

All four kinds are recognized from the outset, even though only the system default can
exist today, so enabling a narrower scope needs no edit here. The scope columns are read
by their ``_id`` attributes so that no row costs a query, and the system default is
recognized by those columns being null rather than by matching the internal
``scope_code`` string.
"""
if profile.competency_taxonomy_id is not None:
return "taxonomy"
if profile.course_id is not None:
return "course"
if profile.organization_id is not None:
return "organization"
return "system_default"
15 changes: 15 additions & 0 deletions src/openedx_learning/applets/cbe/rest_api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
CBE API v1 URLs.
"""

from django.urls import include, path
from rest_framework.routers import DefaultRouter

from . import views

router = DefaultRouter()
router.register("rule_profiles", views.CompetencyRuleProfileView, basename="rule_profile")

urlpatterns = [
path("", include(router.urls)),
]
46 changes: 46 additions & 0 deletions src/openedx_learning/applets/cbe/rest_api/v1/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""
Views for the CBE REST API, v1.
"""
from __future__ import annotations

from django.db.models import QuerySet
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication # type: ignore[import]
from edx_rest_framework_extensions.auth.session.authentication import ( # type: ignore[import]
SessionAuthenticationAllowInactiveUser,
)
from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet

from ...api import get_competency_rule_profiles
from ...models import CompetencyRuleProfile
from ..paginators import CompetencyRuleProfilePagination
from .permissions import CompetencyRuleProfilePermissions
from .serializers import CompetencyRuleProfileSerializer


class CompetencyRuleProfileView(mixins.ListModelMixin, GenericViewSet):
"""
Read the rule profiles this instance defines.

``GET api/cbe/v1/rule_profiles/`` lists them to any signed-in caller, paginated by ``page``
and ``page_size``; ``can_view_competency_rule_profile`` holds the gate.

UNSTABLE: the rule profile family is incomplete, so the create, update, and archive
endpoints still to come may change this shape without a deprecation cycle.

This is a collection even while the seeded system default is the only profile an instance
holds, and it is paginated from the first release: wrapping a bare array in an envelope later
would change the top-level JSON type. A viewset rather than a ListAPIView, so the deferred
create and detail routes can be added as further mixins without touching the URL module.
"""

serializer_class = CompetencyRuleProfileSerializer
permission_classes = [CompetencyRuleProfilePermissions]
pagination_class = CompetencyRuleProfilePagination
# Set here rather than through openedx_tagging's view_auth_classes decorator, which lives in
# another app's REST internals rather than in an API this library publishes.
authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser)

def get_queryset(self) -> QuerySet[CompetencyRuleProfile]:
"""Return the live rule profiles, filtered and ordered by the applet's public API."""
return get_competency_rule_profiles()
57 changes: 57 additions & 0 deletions src/openedx_learning/applets/cbe/rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""
Django rules-based permissions for Competency-Based Education (CBE).
Registered with the ``rules`` permission registry on import. ``src/openedx_learning/rules.py``
is what makes that import happen; see the note there.
"""
from __future__ import annotations

import rules

from openedx_tagging.rules import UserType

from .models import CompetencyRuleProfile

__all__ = [
"can_view_competency_rule_profile",
]


@rules.predicate
def can_view_competency_rule_profile(
user: UserType,
profile: CompetencyRuleProfile | None = None,
) -> bool:
"""
Decide who may read a rule profile, by the profile's scope.
Course authors, not only platform staff, need to read rule profiles, so the collection and
the system default are open to anyone signed in. A taxonomy-scoped profile is as visible as
its taxonomy, which is the gate the criteria tree endpoint uses too, through
``oel_tagging.view_tag``; a profile on a disabled taxonomy stays admin-only. Course- and
organization-scoped profiles are refused to everyone until #645 decides who may see them.
The signed-in requirement is added where the permission is registered, below, so ``has_perm``
refuses an anonymous caller whatever view sits in front of it. This predicate alone does not
check it.
The taxonomy case asks ``has_perm`` rather than calling ``can_view_taxonomy``: openedx-platform
replaces that rule with an org-aware one via ``rules.set_perm``, which a direct call skips.
"""
if profile is None:
# Listing. Any row-level filtering happens in the queryset.
return True
if profile.competency_taxonomy_id is not None:
return user.has_perm("oel_tagging.view_taxonomy", profile.competency_taxonomy)
if profile.course_id is None and profile.organization_id is None:
# The system default: anyone who is signed in may read it.
return True
# Course- and organization-scoped profiles: no authoring flow creates these yet, and neither
# tagging permission describes who may see them. Refused until #645 decides.
return False


rules.add_perm(
"openedx_learning.view_competencyruleprofile",
rules.is_authenticated & can_view_competency_rule_profile,
)
9 changes: 9 additions & 0 deletions src/openedx_learning/rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""
This is where the openedx_learning app's rules-based permissions are registered.
``rules.apps.AutodiscoverRulesConfig`` imports ``<app_package>.rules`` and nothing deeper, so
an applet's permissions reach the registry only by being pulled in from here.
"""
# This wildcard import is okay because the applet rules module declares __all__.
# pylint: disable=wildcard-import
from .applets.cbe.rules import *
16 changes: 16 additions & 0 deletions src/openedx_learning/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
Comment thread
ormsbee marked this conversation as resolved.
Learning API URLs.
Maps each applet onto its own URL segment. A consuming project includes this module and never an
applet package directly, so ``applets/`` stays the internal layout detail that api.py and
models.py already exist to hide.
No ``app_name`` here on purpose: each applet declares its own, so route names stay unique per
applet rather than sharing one flat namespace.
"""

from django.urls import include, path

from .applets.cbe.rest_api import urls as cbe_urls

urlpatterns = [path("cbe/", include(cbe_urls))]
Comment thread
ormsbee marked this conversation as resolved.
23 changes: 22 additions & 1 deletion tests/openedx_learning/applets/cbe/conftest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Shared fixtures for the CBE criteria test modules."""
"""Shared fixtures for the CBE test modules: schema, deletion, tree-integration, status, and REST tests."""
from datetime import datetime, timezone

import pytest
from django.contrib.auth import get_user_model
from organizations.api import ensure_organization
from organizations.models import Organization
from rest_framework.test import APIClient

from openedx_catalog.models import CatalogCourse, CourseRun
from openedx_learning.models import CompetencyCriteriaGroup, CompetencyRuleProfile, CompetencyTaxonomy
Expand Down Expand Up @@ -70,6 +71,12 @@ def _default_rule_profile() -> CompetencyRuleProfile:
)


@pytest.fixture(name="staff_user")
def _staff_user():
"""A user permitted to administer taxonomies, and so every competency rule profile."""
return get_user_model().objects.create(username="staff", email="staff@example.com", is_staff=True)


@pytest.fixture(name="user")
def _user():
"""Create a single learner for use in these tests."""
Expand All @@ -80,3 +87,17 @@ def _user():
def _now() -> datetime:
"""A single UTC timestamp shared by writes in a test."""
return datetime.now(timezone.utc)


@pytest.fixture(name="api_client")
def _api_client() -> APIClient:
"""A REST client for a caller the system cannot identify."""
return APIClient()


@pytest.fixture(name="staff_client")
def _staff_client(staff_user) -> APIClient:
"""A REST client acting as `staff_user`."""
client = APIClient()
client.force_authenticate(user=staff_user)
return client
Loading
Loading