diff --git a/futurex_openedx_extensions/dashboard/docs_src.py b/futurex_openedx_extensions/dashboard/docs_src.py index 330f13c..ed7f8c6 100644 --- a/futurex_openedx_extensions/dashboard/docs_src.py +++ b/futurex_openedx_extensions/dashboard/docs_src.py @@ -6,6 +6,8 @@ from drf_yasg import openapi from edx_api_doc_tools import path_parameter, query_parameter +from futurex_openedx_extensions.helpers.extractors import get_available_optional_field_tags_docs_table + default_responses = { 200: 'Success.', 401: 'Unauthorized access. Authentication credentials were missing or incorrect.', @@ -116,7 +118,13 @@ def responses( 'visible_course_definition': '\n**Note:** A *visible course* is the course with `Course Visibility In Catalog`' ' value set to `about` or `both`; and `visible_to_staff_only` is set to `False`. Courses are visible by default' - ' when created.' + ' when created.', + + 'optional_field_tags': 'Optional fields are not included in the response by default. Caller can request them by' + ' using the `optional_field_tags` parameter. It accepts a comma-separated list of optional field tags. The' + ' following are the available tags along with the fields they include:\n' + '| tag | mapped fields |\n' + '|-----|---------------|\n' } docs_src = { @@ -291,6 +299,13 @@ def responses( 'a search text to filter the results by. The search text will be matched against the `filename` and the' ' `notes`.', ), + query_parameter( + 'optional_field_tags', + str, + repeated_descriptions['optional_field_tags'] + get_available_optional_field_tags_docs_table( + 'futurex_openedx_extensions.dashboard.serializers::DataExportTaskSerializer', + ) + ), ], 'responses': responses(), }, @@ -328,6 +343,13 @@ def responses( int, 'The task ID to retrieve.', ), + query_parameter( + 'optional_field_tags', + str, + repeated_descriptions['optional_field_tags'] + get_available_optional_field_tags_docs_table( + 'futurex_openedx_extensions.dashboard.serializers::DataExportTaskSerializer', + ) + ), ], 'responses': responses(), }, @@ -378,6 +400,13 @@ def responses( ' username, national ID, and email address.', ), common_parameters['include_staff'], + query_parameter( + 'optional_field_tags', + str, + repeated_descriptions['optional_field_tags'] + get_available_optional_field_tags_docs_table( + 'futurex_openedx_extensions.dashboard.serializers::LearnerDetailsForCourseSerializer', + ) + ), common_parameters['download'], query_parameter( 'omit_subsection_name', @@ -425,14 +454,14 @@ def responses( str, 'A search text to filter results, matched against the course\'s ID and display name.', ), + common_parameters['include_staff'], query_parameter( 'optional_field_tags', str, - 'a comma seperated list of optional_fields. The data `exam_scores`, `certificate_url` and `progress` ' - 'in result are optional and can only be added if requested exclusively. Caller can also use `__all__` ' - ' to include all optional fields in result.', + repeated_descriptions['optional_field_tags'] + get_available_optional_field_tags_docs_table( + 'futurex_openedx_extensions.dashboard.serializers::LearnerEnrollmentSerializer', + ) ), - common_parameters['include_staff'], common_parameters['download'], query_parameter( 'omit_subsection_name', diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py index a39a9c9..26f152e 100644 --- a/futurex_openedx_extensions/dashboard/serializers.py +++ b/futurex_openedx_extensions/dashboard/serializers.py @@ -259,7 +259,7 @@ class Meta: 'active_in_course', 'progress', 'certificate_url', - 'exam_scores' + 'exam_scores', ] def __init__(self, *args: Any, **kwargs: Any): diff --git a/futurex_openedx_extensions/helpers/extractors.py b/futurex_openedx_extensions/helpers/extractors.py index 64cabe1..c4e5a96 100644 --- a/futurex_openedx_extensions/helpers/extractors.py +++ b/futurex_openedx_extensions/helpers/extractors.py @@ -1,6 +1,7 @@ """Helper functions for FutureX Open edX Extensions.""" from __future__ import annotations +import importlib import re from dataclasses import dataclass from typing import Any, Dict, List @@ -197,3 +198,74 @@ def get_partial_access_course_ids(fx_permission_info: dict) -> List[str]: ).values_list('id', flat=True) return [str(course_id) for course_id in only_limited_access] + + +def import_from_path(import_path: str) -> Any: + """ + Import a class, function, or a variable from the given path. The path should be formatted as + `module.module.module::class_or_method_or_variable_name`. The path should not contain any whitespace. Only one + `module` is mandatory, but the rest is optional. + + :param import_path: Path to import the class, function, or variable from. + :type import_path: str + :return: Imported class, function, or variable. + :rtype: Any + """ + import_path_pattern = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*::[a-zA-Z_][a-zA-Z0-9_]*$') + + if not import_path_pattern.match(import_path): + raise ValueError( + 'Invalid import path used with import_from_path. The path should be formatted as ' + '`module.module.module::class_or_method_or_variable_name`.' + ) + + module_path, target_object = import_path.split('::', 1) + return getattr(importlib.import_module(module_path), target_object) + + +def get_optional_field_class() -> Any: + return import_from_path( + 'futurex_openedx_extensions.dashboard.serializers::SerializerOptionalMethodField' + ) + + +def get_available_optional_field_tags(serializer_class_path: str) -> Dict[str, List[str]]: + """ + Get the available optional field tags of the serializer class. + + :param serializer_class_path: Name of the serializer class. See `import_from_path` for the format. + :type serializer_class_path: str + :return: Available optional field tags of the serializer class. + :rtype: Dict(str, List[str]) + """ + result: Dict[str, List[str]] = {} + for field_name, field in import_from_path(serializer_class_path)().fields.items(): + if not issubclass(field.__class__, get_optional_field_class()): + continue + + for tag in field.field_tags: + if tag not in result: + result[tag] = [] + result[tag].append(field_name) + + return result + + +def get_available_optional_field_tags_docs_table(serializer_class_path: str) -> str: + """ + Get the available optional field tags of the serializer class in a markdown table format. + + :param serializer_class_path: Name of the serializer class. See `import_from_path` for the format. + :type serializer_class_path: str + :return: Available optional field tags of the serializer class in a markdown table format. + :rtype: str + """ + tags = get_available_optional_field_tags(serializer_class_path) + tags = dict(sorted(tags.items())) + result = '' + for tag, fields in tags.items(): + tag = tag.replace('_', '\\_') + result += f'| {tag} | {", ".join([f"`{field}`" for field in fields])} |\n' + result += '----------------\n' + + return result diff --git a/tests/test_helpers/test_extractors.py b/tests/test_helpers/test_extractors.py index f4b8f80..c14969c 100644 --- a/tests/test_helpers/test_extractors.py +++ b/tests/test_helpers/test_extractors.py @@ -4,19 +4,48 @@ import pytest from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from futurex_openedx_extensions.dashboard.serializers import SerializerOptionalMethodField from futurex_openedx_extensions.helpers import constants as cs from futurex_openedx_extensions.helpers.exceptions import FXCodedException from futurex_openedx_extensions.helpers.extractors import ( DictHashcode, DictHashcodeSet, + get_available_optional_field_tags, + get_available_optional_field_tags_docs_table, get_course_id_from_uri, get_first_not_empty_item, + get_optional_field_class, get_orgs_of_courses, get_partial_access_course_ids, + import_from_path, verify_course_ids, ) +class DummySerializerWithOptionalMethodField: # pylint: disable=too-few-public-methods + """Dummy class for testing.""" + def __init__(self, *args, **kwargs): + """Initialize the DummySerializer.""" + self._fields = { + 'fieldA': 'not optional', + 'fieldB': 'not optional', + 'fieldC': SerializerOptionalMethodField(field_tags=['tag1', 'tag2']), + 'fieldD': SerializerOptionalMethodField(field_tags=['tag1', 'tag3']), + } + + @property + def fields(self): + return self._fields + + +@pytest.fixture +def mock_import_from_path(): + """Mock the import_from_path function for testing.""" + with patch('futurex_openedx_extensions.helpers.extractors.import_from_path') as mock_import: + mock_import.return_value = DummySerializerWithOptionalMethodField + yield mock_import + + @pytest.mark.parametrize('items, expected, error_message', [ ([0, None, False, '', 3, 'hello'], 3, 'Test with a list containing truthy and falsy values'), ([0, None, False, ''], None, 'Test with a list containing only falsy values'), @@ -314,3 +343,82 @@ def test_get_partial_access_course_ids_found(base_data, fx_permission_info): # result = get_partial_access_course_ids(fx_permission_info) assert isinstance(result, list) assert result == ['course-v1:ORG3+1+1'] + + +@pytest.mark.parametrize( + 'import_path, expected_call, expected_result', + [ + ('valid_module::valid_object', 'valid_module', 'mocked_object'), + ('valid_module.valid_module::valid_object', 'valid_module.valid_module', 'mocked_object'), + ], +) +@patch('futurex_openedx_extensions.helpers.extractors.importlib.import_module') +def test_import_from_path_valid(mock_import_module, import_path, expected_call, expected_result): + """Verify that import_from_path returns the expected object from the module.""" + mocked_module = Mock() + mock_import_module.return_value = mocked_module + mocked_module.valid_object = expected_result + + result = import_from_path(import_path) + assert result == expected_result + mock_import_module.assert_called_once_with(expected_call) + + +@pytest.mark.parametrize( + 'import_path, error_reason', + [ + ('module:object', 'Single colon'), + ('module::object name', 'Whitespace in object'), + ('module name::object', 'Whitespace in module'), + ('::object', 'Missing module'), + ('123module::object', 'Invalid module name'), + ], +) +def test_import_from_path_invalid(import_path, error_reason): # pylint: disable=unused-argument + """Verify that import_from_path raises a ValueError for an invalid import path.""" + with pytest.raises(ValueError, match='Invalid import path used with import_from_path'): + import_from_path(import_path) + + +def test_get_optional_field_class(): + """Verify that get_optional_field_class returns the expected class.""" + assert get_optional_field_class() == SerializerOptionalMethodField + + +@patch('futurex_openedx_extensions.helpers.extractors.get_optional_field_class') +def test_get_available_optional_field_tags( + mock_get_optional_field_class, mock_import_from_path, +): # pylint: disable=unused-argument, redefined-outer-name + """Verify that get_available_optional_field_tags returns the expected tags for a serializer.""" + mock_get_optional_field_class.return_value = SerializerOptionalMethodField + tags = get_available_optional_field_tags('serializer class path, not being used in this test because of the mock') + assert tags == { + '__all__': ['fieldC', 'fieldD'], + 'tag1': ['fieldC', 'fieldD'], + 'tag2': ['fieldC'], + 'tag3': ['fieldD'], + } + + +@patch('futurex_openedx_extensions.helpers.extractors.get_available_optional_field_tags') +def test_get_available_optional_field_tags_docs_table( + mock_get_tags, mock_import_from_path, +): # pylint: disable=unused-argument, redefined-outer-name + """Verify that get_available_optional_field_tags_docs_table returns the expected table.""" + mock_get_tags.return_value = { + '__all__': ['fieldA', 'field_B', 'field__C', '__fieldD__'], + 'tag1': ['fieldA', '__fieldD__'], + 'tag2': ['field_B'], + 'tag3': ['field__C', '__fieldD__'], + } + + result = get_available_optional_field_tags_docs_table( + 'serializer class path, not being used in this test because of the mock', + ) + assert result == ( + '| \\_\\_all\\_\\_ | `fieldA`, `field_B`, `field__C`, `__fieldD__` |\n' + '| tag1 | `fieldA`, `__fieldD__` |\n' + '| tag2 | `field_B` |\n' + '| tag3 | `field__C`, `__fieldD__` |\n' + '----------------\n' + )