Skip to content

Commit

Permalink
feat: better docs for optional_field_tags
Browse files Browse the repository at this point in the history
  • Loading branch information
shadinaif committed Dec 25, 2024
1 parent 658bce2 commit 235936f
Show file tree
Hide file tree
Showing 5 changed files with 219 additions and 7 deletions.
2 changes: 1 addition & 1 deletion futurex_openedx_extensions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""One-line description for README and other doc files."""

__version__ = '0.9.20'
__version__ = '0.9.21'
39 changes: 34 additions & 5 deletions futurex_openedx_extensions/dashboard/docs_src.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(),
},
Expand Down Expand Up @@ -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(),
},
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion futurex_openedx_extensions/dashboard/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ class Meta:
'active_in_course',
'progress',
'certificate_url',
'exam_scores'
'exam_scores',
]

def __init__(self, *args: Any, **kwargs: Any):
Expand Down
72 changes: 72 additions & 0 deletions futurex_openedx_extensions/helpers/extractors.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
111 changes: 111 additions & 0 deletions tests/test_helpers/test_extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,50 @@
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,
)

# import pipe
from posix import cpu_count
a = cpu_count()

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'),
Expand Down Expand Up @@ -314,3 +346,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'
)

0 comments on commit 235936f

Please sign in to comment.