Skip to content

Commit ee25f8a

Browse files
authored
[FC-0099] feat: implement get_object method in scope data classes (#124)
1 parent ab9e2fb commit ee25f8a

File tree

4 files changed

+178
-8
lines changed

4 files changed

+178
-8
lines changed

CHANGELOG.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ Unreleased
1616

1717
*
1818

19+
0.10.0 - 2025-10-28
20+
*******************
21+
22+
Added
23+
=====
24+
25+
* New ``get_object()`` method in ScopeData to retrieve underlying domain objects
26+
* Implementation of ``get_object()`` for ContentLibraryData with canonical key validation
27+
28+
Changed
29+
=======
30+
31+
* Refactor ``ContentLibraryData.exists()`` to use ``get_object()`` internally
32+
1933
0.9.1 - 2025-10-28
2034
******************
2135

openedx_authz/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44

55
import os
66

7-
__version__ = "0.9.1"
7+
__version__ = "0.10.0"
88

99
ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))

openedx_authz/api/data.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Data classes and enums for representing roles, permissions, and policies."""
22

3+
from __future__ import annotations
4+
35
import re
46
from abc import abstractmethod
57
from enum import Enum
6-
from typing import ClassVar, Literal, Type
8+
from typing import Any, ClassVar, Literal, Type
79

810
from attrs import define
911
from opaque_keys import InvalidKeyError
@@ -319,6 +321,20 @@ def validate_external_key(cls, _: str) -> bool:
319321
"""
320322
return True
321323

324+
@abstractmethod
325+
def get_object(self) -> Any | None:
326+
"""Retrieve the underlying domain object that this scope represents.
327+
328+
This method fetches the actual Open edX object (e.g., ContentLibrary, Organization)
329+
associated with this scope's external_key. Subclasses should implement this to return
330+
their specific object types.
331+
332+
Returns:
333+
Any | None: The domain object associated with this scope, or None if the object
334+
does not exist or cannot be retrieved.
335+
"""
336+
raise NotImplementedError("Subclasses must implement get_object method.")
337+
322338
@abstractmethod
323339
def exists(self) -> bool:
324340
"""Check if the scope exists.
@@ -366,6 +382,15 @@ def library_id(self) -> str:
366382
"""
367383
return self.external_key
368384

385+
@property
386+
def library_key(self) -> LibraryLocatorV2:
387+
"""The LibraryLocatorV2 object for the content library.
388+
389+
Returns:
390+
LibraryLocatorV2: The library locator object.
391+
"""
392+
return LibraryLocatorV2.from_string(self.library_id)
393+
369394
@classmethod
370395
def validate_external_key(cls, external_key: str) -> bool:
371396
"""Validate the external_key format for ContentLibraryData.
@@ -382,18 +407,38 @@ def validate_external_key(cls, external_key: str) -> bool:
382407
except InvalidKeyError:
383408
return False
384409

410+
def get_object(self) -> ContentLibrary | None:
411+
"""Retrieve the ContentLibrary instance associated with this scope.
412+
413+
This method converts the library_id to a LibraryLocatorV2 key and queries the
414+
database to fetch the corresponding ContentLibrary object.
415+
416+
Returns:
417+
ContentLibrary | None: The ContentLibrary instance if found in the database,
418+
or None if the library does not exist or has an invalid key format.
419+
420+
Examples:
421+
>>> library_scope = ContentLibraryData(external_key='lib:DemoX:CSPROB')
422+
>>> library_obj = library_scope.get_object() # ContentLibrary object
423+
"""
424+
try:
425+
library_obj = ContentLibrary.objects.get_by_key(self.library_key)
426+
# Validate canonical key: get_by_key is case-insensitive, but we require exact match
427+
# This ensures authorization uses canonical library IDs consistently
428+
if library_obj.library_key != self.library_key:
429+
raise ContentLibrary.DoesNotExist
430+
except (InvalidKeyError, ContentLibrary.DoesNotExist):
431+
return None
432+
433+
return library_obj
434+
385435
def exists(self) -> bool:
386436
"""Check if the content library exists.
387437
388438
Returns:
389439
bool: True if the content library exists, False otherwise.
390440
"""
391-
try:
392-
library_key = LibraryLocatorV2.from_string(self.library_id)
393-
ContentLibrary.objects.get_by_key(library_key=library_key)
394-
return True
395-
except ContentLibrary.DoesNotExist:
396-
return False
441+
return self.get_object() is not None
397442

398443
def __str__(self):
399444
"""Human readable string representation of the content library."""

openedx_authz/tests/api/test_data.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Test data for the authorization API."""
22

3+
from unittest.mock import Mock, patch
4+
35
from ddt import data, ddt, unpack
46
from django.test import TestCase
7+
from opaque_keys.edx.locator import LibraryLocatorV2
58

69
from openedx_authz.api.data import (
710
ActionData,
@@ -507,3 +510,111 @@ def test_role_assignment_data_repr(self):
507510

508511
expected_repr = "user^john_doe => [role^instructor, role^library_admin] @ lib^lib:DemoX:CSPROB"
509512
self.assertEqual(actual_repr, expected_repr)
513+
514+
515+
@ddt
516+
class TestContentLibraryData(TestCase):
517+
"""Test the ContentLibraryData class."""
518+
519+
@patch("openedx_authz.api.data.ContentLibrary")
520+
def test_get_object_success(self, mock_content_library_model):
521+
"""Test get_object returns ContentLibrary when it exists with valid key.
522+
523+
Expected Result:
524+
- Returns the ContentLibrary object when library exists
525+
- Library key matches exactly (canonical validation passes)
526+
"""
527+
library_id = "lib:DemoX:CSPROB"
528+
library_scope = ContentLibraryData(external_key=library_id)
529+
mock_library_obj = Mock()
530+
mock_library_obj.library_key = library_scope.library_key
531+
mock_content_library_model.objects.get_by_key.return_value = mock_library_obj
532+
533+
result = library_scope.get_object()
534+
535+
self.assertEqual(result, mock_library_obj)
536+
mock_content_library_model.objects.get_by_key.assert_called_once_with(library_scope.library_key)
537+
538+
@patch("openedx_authz.api.data.ContentLibrary")
539+
def test_get_object_does_not_exist(self, mock_content_library_model):
540+
"""Test get_object returns None when library does not exist.
541+
542+
Expected Result:
543+
- Returns None when ContentLibrary.DoesNotExist is raised
544+
"""
545+
library_id = "lib:DemoX:NonExistent"
546+
library_scope = ContentLibraryData(external_key=library_id)
547+
mock_content_library_model.DoesNotExist = Exception
548+
mock_content_library_model.objects.get_by_key.side_effect = mock_content_library_model.DoesNotExist
549+
550+
result = library_scope.get_object()
551+
552+
self.assertIsNone(result)
553+
554+
@patch("openedx_authz.api.data.ContentLibrary")
555+
def test_get_object_invalid_key_format(self, mock_content_library_model):
556+
"""Test get_object returns None when library_id has invalid format.
557+
558+
Expected Result:
559+
- Returns None when InvalidKeyError is raised during key parsing
560+
"""
561+
mock_content_library_model.DoesNotExist = Exception
562+
library_scope = ContentLibraryData(external_key="invalid-library-format")
563+
564+
result = library_scope.get_object()
565+
566+
self.assertIsNone(result)
567+
mock_content_library_model.objects.get_by_key.assert_not_called()
568+
569+
@patch("openedx_authz.api.data.ContentLibrary")
570+
def test_get_object_non_canonical_key(self, mock_content_library_model):
571+
"""Test get_object returns None when library key is not canonical.
572+
573+
This test verifies the canonical key validation: get_by_key is case-insensitive,
574+
but we require exact match to ensure authorization uses canonical library IDs.
575+
576+
Expected Result:
577+
- Returns None when retrieved library's key doesn't match exactly
578+
- Simulates case where user provides 'lib:demox:csprob' but canonical is 'lib:DemoX:CSPROB'
579+
"""
580+
library_id = "lib:DemoX:CSPROB"
581+
library_key = LibraryLocatorV2.from_string(library_id)
582+
# Convert to lowercase to simulate case-insensitive comparison
583+
library_scope = ContentLibraryData(external_key=library_id.lower())
584+
mock_content_library_model.objects.get_by_key.return_value = Mock(library_key=library_key)
585+
mock_content_library_model.DoesNotExist = Exception
586+
587+
result = library_scope.get_object()
588+
589+
self.assertIsNone(result)
590+
591+
@patch("openedx_authz.api.data.ContentLibrary")
592+
def test_exists_returns_true_when_library_exists(self, mock_content_library_model):
593+
"""Test exists() returns True when get_object() returns a library.
594+
595+
Expected Result:
596+
- exists() returns True when library object is found
597+
"""
598+
library_id = "lib:DemoX:CSPROB"
599+
library_scope = ContentLibraryData(external_key=library_id)
600+
mock_content_library_model.objects.get_by_key.return_value = Mock(library_key=library_scope.library_key)
601+
602+
result = library_scope.exists()
603+
604+
self.assertTrue(result)
605+
606+
@patch("openedx_authz.api.data.ContentLibrary")
607+
def test_exists_returns_false_when_library_does_not_exist(self, mock_content_library_model):
608+
"""Test exists() returns False when get_object() returns None.
609+
610+
Expected Result:
611+
- exists() returns False when library is not found
612+
"""
613+
library_id = "lib:DemoX:NonExistent"
614+
library_scope = ContentLibraryData(external_key=library_id)
615+
mock_content_library_model.DoesNotExist = Exception
616+
mock_content_library_model.objects.get_by_key.side_effect = mock_content_library_model.DoesNotExist
617+
618+
result = library_scope.exists()
619+
620+
self.assertFalse(result)

0 commit comments

Comments
 (0)