From fbdb659a610aed1d57e1b23f512279e99dc020ce Mon Sep 17 00:00:00 2001 From: Shadi Naif Date: Wed, 11 Dec 2024 13:37:36 +0300 Subject: [PATCH] feat: swagger docs --- futurex_openedx_extensions/__init__.py | 2 +- .../dashboard/docs_src.py | 515 ++++++++++++++++++ .../dashboard/docs_utils.py | 46 ++ .../dashboard/serializers.py | 30 +- futurex_openedx_extensions/dashboard/views.py | 38 +- futurex_openedx_extensions/helpers/models.py | 2 +- requirements/test-constraints-palm.txt | 1 + requirements/test-constraints-redwood.txt | 1 + requirements/test.in | 1 + tests/test_dashboard/test_docs_utils.py | 111 ++++ 10 files changed, 719 insertions(+), 28 deletions(-) create mode 100644 futurex_openedx_extensions/dashboard/docs_src.py create mode 100644 futurex_openedx_extensions/dashboard/docs_utils.py create mode 100644 tests/test_dashboard/test_docs_utils.py diff --git a/futurex_openedx_extensions/__init__.py b/futurex_openedx_extensions/__init__.py index 8df86f1e..a360b6bd 100644 --- a/futurex_openedx_extensions/__init__.py +++ b/futurex_openedx_extensions/__init__.py @@ -1,3 +1,3 @@ """One-line description for README and other doc files.""" -__version__ = '0.9.19' +__version__ = '0.9.20' diff --git a/futurex_openedx_extensions/dashboard/docs_src.py b/futurex_openedx_extensions/dashboard/docs_src.py new file mode 100644 index 00000000..5a963184 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/docs_src.py @@ -0,0 +1,515 @@ +"""Helpers for generating Swagger documentation for the FutureX Open edX Extensions API.""" +from __future__ import annotations + +from typing import Dict, List + +from drf_yasg import openapi +from edx_api_doc_tools import path_parameter, query_parameter + +default_responses = { + 200: 'Success.', + 401: 'Unauthorized access. Authentication credentials were missing or incorrect.', + 400: 'Bad request. Details in the response body.', + 403: 'Forbidden access. Details in the response body.', + 404: 'Resource not found, or not accessible to the user.', +} + + +def responses( + overrides: Dict[int, str] | None = None, + remove: List[int] | None = None, +) -> Dict[int, str]: + """ + Generate responses for the API endpoint. + + :param overrides: Optional overrides for the default responses. + :type overrides: dict + :param remove: Optional list of status codes to remove from the default responses. + :type remove: list + :return: Responses for the API endpoint. + :rtype: dict + """ + result = {**default_responses, **(overrides or {})} + if remove: + for status_code in remove: + result.pop(status_code, None) + return result + + +common_parameters = { + 'download': query_parameter( + 'download', + str, + 'Trigger a data export task for the results. Currently only `download=csv` is supported. The response will no' + ' longer be a list of objects, but a JSON object with `export_task_id` field. Then the `export_task_id` can' + ' be used with the `/fx/export/v1/tasks/` endpoints.\n' + '\nNote: this parameter will disable pagination options `page` and `page_size`. Therefore, the exported CSV' + ' will contain all the result\'s records.', + ), + 'include_staff': query_parameter( + 'include_staff', + int, + 'include staff users in the result `1` or `0`. Default is `0`. Any value other than `1` is considered as `0`. ' + 'A staff user is any user who has a role within the tenant.', + ), + 'tenant_ids': query_parameter( + 'tenant_ids', + str, + 'a comma separated list of tenant ids to filter the results by. If not provided, the system will assume all' + ' tenants that are accessible to the user.', + ), +} + +common_path_parameters = { + 'username-learner': path_parameter( + 'username', + str, + 'The username of the learner to retrieve information for.', + ), + 'username-staff': path_parameter( + 'username', + str, + 'The username of the staff user to retrieve information for.', + ), +} + +repeated_descriptions = { + 'roles_overview': '\nCategories of roles:\n' + '-----------------------------------------------------\n' + '| Role ID | Available in GET | Can be edited | Role level |\n' + '|---------|------------------|---------------|------|\n' + '| course_creator_group | Yes | No | global role |\n' + '| support | Yes | No | global role |\n' + '| org_course_creator_group | Yes | Yes | tenant-wide only |\n' + '| beta_testers | Yes | Yes | course-specific only |\n' + '| ccx_coach | Yes | Yes | course-specific only |\n' + '| finance_admin | Yes | Yes | course-specific only |\n' + '| staff | Yes | Yes | tenant-wide or course-specific |\n' + '| data_researcher | Yes | Yes | tenant-wide or course-specific |\n' + '| instructor | Yes | Yes | tenant-wide or course-specific |\n' + '-----------------------------------------------------\n' + '\nThe above table shows the available roles, their availability in the GET response, if they can be edited,' + ' and the role level.\n' + '\n**Security note**: having access to this endpoint does not mean the caller can assign any role to any user.' + ' When using edit-role APIs; caller must be a `staff` or `org_course_creator_group` on the tenant:\n' + '* System-staff/Superuser can do all operations (obviously!)\n' + '* Tenant `staff` can do all operations except removing **tenant-wide** `staff` role from a user (including self)\n' + '* `org_course_creator_group` can do all operations on the **course-level**, not the **tenant-level**. For' + ' example, she can add `staff` role for another user on one course, but cannot add it as **tenant-wide**.' + ' She can also remove **course-specific** roles from users, but cannot remove **tenant-wide** roles from any' + ' user (including self)', +} + +docs_src = { + 'AccessibleTenantsInfoView.get': { + 'summary': 'Get information about accessible tenants for a user', + 'description': 'Get information about accessible tenants for a user. The caller must be a staff user or an' + ' anonymous user.', + 'parameters': [ + query_parameter( + 'username_or_email', + str, + '(**required**) The username or email of the user to retrieve the accessible tenants for.' + ), + ], + 'responses': responses(), + }, + + 'CourseStatusesView.get': { + 'summary': 'Get number of courses of each status in the tenants', + 'description': 'The response will include the number of courses in the selected tenants for each status. The' + ' possible statuses are:\n' + '- `self_active`: the course is self-paced and active.\n' + '- `self_archived`: the course is self-paced and archived.\n' + '- `self_upcoming`: the course is self-paced and still not active yet.\n' + '- `active`: the course is instructor-paced and active.\n' + '- `archived`: the course is instructor-paced and archived.\n' + '- `upcoming`: the course is instructor-paced and not active yet.\n' + '\nNote: the count includes all courses regardless of their visibility status.', + 'parameters': [ + common_parameters['tenant_ids'], + ], + }, + + 'CoursesView.get': { + 'summary': 'Get the list of courses in the tenants', + 'description': 'Get the list of courses in the tenants. Which is the list of all courses available in the' + ' selected tenants regardless of their visibility status.', + 'parameters': [ + common_parameters['tenant_ids'], + query_parameter( + 'search_text', + str, + 'a search text to filter the results by. The search text will be matched against the course\'s ID and' + ' display name.', + ), + query_parameter( + 'sort', + str, + 'Which field to use when ordering the results. Available fields are:\n' + '- `display_name`: (**default**) course display name.\n' + '- `id`: course ID.\n' + '- `self_paced`: course self-paced status.\n' + '- `org`: course organization.\n' + '- `enrolled_count`: course enrolled learners count.\n' + '- `certificates_count`: course issued certificates count.\n' + '- `completion_rate`: course completion rate.\n' + '\nAdding a dash before the field name will reverse the order. For example, `-display_name` will sort' + ' the results by the course display name in descending order.', + ), + common_parameters['include_staff'], + common_parameters['download'], + ], + 'responses': responses(), + }, + + 'DataExportManagementView.list': { + 'summary': 'Get the list of data export tasks for the caller', + 'description': 'Get the list of data export tasks for the caller.', + 'parameters': [ + query_parameter( + 'view_name', + str, + 'The name of the view to filter the results by. The view name is the name of the endpoint that' + ' generated the data export task. ', + ), + query_parameter( + 'related_id', + str, + 'The related ID to filter the results by. The related ID is the ID of the object that the data export', + ), + query_parameter( + 'sort', + str, + 'Which field to use when ordering the results according to any of the result fields. The default is' + ' `-id` (sorting descending by the task ID).', + ), + query_parameter( + 'search_text', + str, + 'a search text to filter the results by. The search text will be matched against the `filename` and the' + ' `notes`.', + ), + ], + 'responses': responses(), + }, + + 'DataExportManagementView.partial_update': { + 'summary': 'Set the note of the task', + 'description': 'Set an optional note for the task. The note is a free text field that can be used to describe' + ' the task so the user can remember the purpose of the task later.', + 'body': openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'notes': openapi.Schema( + type=openapi.TYPE_STRING, + description='A text note to set for the task', + example='Weekly report as requested by boss!', + ), + }, + ), + 'parameters': [ + path_parameter( + 'id', + int, + 'The task ID to retrieve.', + ), + ], + 'responses': responses(), + }, + + 'DataExportManagementView.retrieve': { + 'summary': 'Get details of a single task', + 'description': 'Get details of a single task by ID. The task must be owned by the caller.', + 'parameters': [ + path_parameter( + 'id', + int, + 'The task ID to retrieve.', + ), + ], + 'responses': responses(), + }, + + 'GlobalRatingView.get': { + 'summary': 'Get global rating statistics for the tenants', + 'description': 'Get global rating statistics for the tenants. The response will include the average rating and' + ' the total number of ratings for the selected tenants, plus the number of ratings for each rating value from' + ' 1 to 5.', + 'parameters': [ + common_parameters['tenant_ids'], + ], + 'responses': responses(), + }, + + 'LearnerCoursesView.get': { + 'summary': 'Get the list of courses for a specific learner', + 'description': 'Get the list of courses for a specific learner using the `username`. The caller must have' + ' access to the learner\'s tenant through a tenant-role, or access to a course where the learner is enrolled' + '.\n' + '\nNote: this endpoint will return `404` when inquiring for a staff user; unless `include_staff` is set to `1`', + 'parameters': [ + common_path_parameters['username-learner'], + common_parameters['tenant_ids'], + common_parameters['include_staff'], + ], + 'responses': responses(), + }, + + 'LearnersDetailsForCourseView.get': { + 'summary': 'Get the list of learners for a specific course', + 'description': 'Get the list of learners for a specific course using the `course_id`. The caller must have' + ' access to the course.', + 'parameters': [ + path_parameter( + 'course_id', + str, + 'The course ID to retrieve the learners for.', + ), + common_parameters['tenant_ids'], + query_parameter( + 'search_text', + str, + 'a search text to filter the results by. The search text will be matched against the user\'s full name,' + ' username, national ID, and email address.', + ), + common_parameters['include_staff'], + common_parameters['download'], + query_parameter( + 'omit_subsection_name', + int, + 'Omit the subsection name from the response. Can be `0` or `1`. This is useful when `exam_scores`' + ' optional fields are requested; it\'ll omit the subsection names for cleaner representation of the' + ' data. Default is `0`. Any value other than `1` is considered as `0`.', + ), + ], + 'responses': responses(), + }, + + 'LearnerInfoView.get': { + 'summary': 'Get learner\'s information', + 'description': 'Get full information for a specific learner using the `username`. The caller must have access' + ' to the learner\'s tenant through a tenant-role, or access to a course where the learner is enrolled.', + 'parameters': [ + common_path_parameters['username-learner'], + common_parameters['tenant_ids'], + common_parameters['include_staff'], + ], + 'responses': responses(), + }, + + 'LearnersView.get': { + 'summary': 'Get the list of learners in the tenants', + 'description': 'Get the list of learners in the tenants. Which is the list of all learners having at least one' + ' enrollment in any course in the selected tenants, or had their user registered for the first time within' + ' the selected tenants. When using the `include_staff` parameter, the response will also include staff' + ' users who have a role within the tenant regardless of enrollments or user registration.', + 'parameters': [ + common_parameters['tenant_ids'], + query_parameter( + 'search_text', + str, + 'a search text to filter the results by. The search text will be matched against the user\'s full name,' + ' username, national ID, and email address.', + ), + common_parameters['include_staff'], + common_parameters['download'], + ], + 'responses': responses(), + }, + + 'MyRolesView.get': { + 'summary': 'Get the roles of the caller', + 'description': 'Get details of the caller\'s roles.', + 'parameters': [ + common_parameters['tenant_ids'], + ], + 'responses': responses(), + }, + + 'TotalCountsView.get': { + 'summary': 'Get total counts statistics', + 'description': 'Get total counts for certificates, courses, hidden_courses, learners, and enrollments. The' + ' `include_staff` parameter does not affect the counts of **course** and **hidden-courses**.', + 'parameters': [ + common_parameters['tenant_ids'], + query_parameter( + 'stats', + str, + 'a comma-separated list of the types of count statistics to include in the response. Available count' + ' statistics are:\n' + '- `certificates`: total number of issued certificates in the selected tenants.\n' + '- `courses`: total number of catalog-available courses in the selected tenants.\n' + '- `hidden_courses`: total number of hidden courses in the selected tenants.\n' + '- `learners`: total number of learners in the selected tenants. Be aware that the same learner might' + ' be accessing multiple tenants, and will be counted on every related tenant.\n' + '- `enrollments`: total number of enrollments in the selected tenants.\n', + ), + common_parameters['include_staff'], + ], + 'responses': responses(), + }, + + 'UserRolesManagementView.create': { + 'summary': 'Add a role to one or more users in the tenants', + 'description': f'Add a role to one or more users in the tenants.\n{repeated_descriptions["roles_overview"]}', + 'body': openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'tenant_ids': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema(type=openapi.TYPE_INTEGER), + description='The tenants we\'re adding these user-roles to. If more than one tenant is provided,' + ' then tenant_wide must be set `1`', + example=[1, 2], + ), + 'users': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema(type=openapi.TYPE_STRING), + description='List of user identifiers (username, email, or ID). Mixing the identifier types is' + ' allowed. Only one of any of the three identifiers is required for each user', + example=['user1', 'user2@example.com', 99], + ), + 'role': openapi.Schema( + type=openapi.TYPE_STRING, + description='Role name to assign', + example='staff', + ), + 'tenant_wide': openapi.Schema( + type=openapi.TYPE_INTEGER, + description='`0` or `1` to specify if the role is tenant-wide or not. If set to `1`, then' + ' `courses_ids` must be `Null`, empty array, or omitted. Otherwise; `courses_ids` must be' + ' filled with at least one course ID', + example=0, + ), + 'course_ids': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema(type=openapi.TYPE_STRING), + description='Course IDs for affected courses. See `tenant_wide` note above', + example=['course-v1:org+course+001', 'course-v1:org+course+002'], + ), + }, + required=['tenant_ids', 'users', 'role', 'tenant_wide'] + ), + 'parameters': [ + ], + 'responses': responses( + overrides={ + 201: 'Operation processed. The returned JSON contains more details:\n' + '- `added`: the list of users successfully added to the role. The identifier is the same as the one' + ' sent, ID, username, or email\n' + '- `updated`: the list of users who had their role updated according to the request because they had' + ' that role already but with different configuration\n' + '- `not_updated`: users who already have the exact requested amendment\n' + '- `failed`: for every failing user: the structure contain the user information + reason code (numeric)' + ' + reason message\n' + '\nPossible reason codes:\n' + '------------------------------\n' + '| Code | Description |\n' + '|------|-------------|\n' + '| 1001 | The given user does not exist within the query. For example, it might exist in the database' + ' but not available within the request tenants |\n' + '| 1002 | The given user is not active (`is_active` = `False`) |\n' + '| 1003 | The given email is used as a username for another user. Conflict data to be resolved by the' + ' superuser |\n' + '| 1004 | The given user is not accessible by the caller |\n' + '| 2001 | Error while deleting role |\n' + '| 2002 | Error while adding role to a user |\n' + '| 2003 | Dirty data found for user which prevents the requested operation. For example, adding' + ' `org_course_creator_group` to a user who has that role already exist without the required' + ' `CourseCreator` record in the database (caused by old bad entry) |\n' + '| 2004 | The given role is unsupported |\n' + '| 2005 | Bad request entry for the requested roles operation |\n' + '| 2006 | Course creator role is not granted or not present (caused by old bad entry) |\n' + '| 2007 | Error while updating roles for user |\n' + '| 5001 | Course creator record not found (caused by old bad entry) |\n' + '------------------------------\n' + }, + remove=[200, 400], + ), + }, + + 'UserRolesManagementView.destroy': { + 'summary': 'Delete all roles of one user in all given tenants', + 'description': f'Delete all roles of one user in all given tenants.\n{repeated_descriptions["roles_overview"]}', + 'parameters': [ + common_path_parameters['username-staff'], + openapi.Parameter( + name='tenant_ids', + in_=openapi.IN_QUERY, + type=openapi.TYPE_STRING, + description='Comma-separated list of tenant IDs to delete the user-roles from', + required=True, + ), + ], + 'responses': responses( + overrides={204: 'The user-roles have been deleted successfully.'}, + remove=[200], + ), + }, + + 'UserRolesManagementView.list': { + 'summary': 'Get the list of roles of users in the tenants', + 'description': 'Get the list of roles of users in the tenants', + 'parameters': [ + common_parameters['tenant_ids'], + ], + 'responses': responses(), + }, + + 'UserRolesManagementView.retrieve': { + 'summary': 'Get the roles of a single users in the tenants', + 'description': 'Get the roles of a single users in the tenants', + 'parameters': [ + common_path_parameters['username-staff'], + common_parameters['tenant_ids'], + ], + 'responses': responses(), + }, + + 'UserRolesManagementView.update': { + 'summary': 'Change the roles of one user in one tenant', + 'description': 'Change the roles of one user in one tenant. The updated roles will replace all the existing.\n' + f'{repeated_descriptions["roles_overview"]}' + ' roles of the user in the tenant.', + 'body': openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'tenant_id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description='The tenant ID to update the user roles in', + example=1, + ), + 'tenant_roles': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema(type=openapi.TYPE_STRING), + description='List of role names to assign to the user as tenant-wide roles', + example=['staff', 'org_course_creator_group'], + ), + 'course_roles': openapi.Schema( + type=openapi.TYPE_OBJECT, + additional_properties=openapi.Schema(type=openapi.TYPE_STRING), + description='Dictionary of course IDs and their roles. The course ID is the key, and the value is' + ' a list of role names to assign to the user for that course', + example={ + 'course-v1:org+course+001': ['instructor', 'ccx_coach'], + 'course-v1:org+course+002': ['data_researcher', 'ccx_coach'], + }, + ), + }, + required=['tenant_id', 'tenant_roles', 'course_roles'] + ), + 'parameters': [ + common_path_parameters['username-staff'], + ], + 'responses': responses(), + }, + + 'VersionInfoView.get': { + 'summary': 'Get fx-openedx-extentions running version', + 'description': 'Get fx-openedx-extentions running version. The caller must be a system staff.', + 'parameters': [ + ], + 'responses': responses(remove=[400, 404]), + }, +} diff --git a/futurex_openedx_extensions/dashboard/docs_utils.py b/futurex_openedx_extensions/dashboard/docs_utils.py new file mode 100644 index 00000000..6c89a984 --- /dev/null +++ b/futurex_openedx_extensions/dashboard/docs_utils.py @@ -0,0 +1,46 @@ +"""Helpers for generating Swagger documentation for the FutureX Open edX Extensions API.""" +from __future__ import annotations + +import copy +from typing import Any, Callable + +from edx_api_doc_tools import schema, schema_for + +from futurex_openedx_extensions.dashboard.docs_src import docs_src + + +def docs(class_method_name: str) -> Callable: + """ + Decorator to add documentation to a class method. + + :param class_method_name: The name of the class method. + :type class_method_name + :return: The documentation for the class method. + :rtype: dict + """ + def _schema(view_func: Any) -> Any: + """Decorate a view class with the specified schema.""" + if not callable(view_func): + raise ValueError( + f'docs decorator must be applied to a callable function or class. Got: {view_func.__class__.__name__}' + ) + + try: + docs_copy = copy.deepcopy(docs_src[class_method_name]) + except KeyError as error: + raise ValueError(f'docs_utils Error: no documentation found for {class_method_name}') from error + + if view_func.__class__.__name__ == 'function': + return schema(**docs_src[class_method_name])(view_func) + + method_name = class_method_name.split('.')[1] + docstring = docs_copy.pop('summary', '') + '\n' + docs_copy.pop('description', '') # type: ignore + if docstring == '\n': + docstring = None + return schema_for( + method_name, + docstring=docstring, + **docs_copy + )(view_func) + + return _schema diff --git a/futurex_openedx_extensions/dashboard/serializers.py b/futurex_openedx_extensions/dashboard/serializers.py index 98b3f914..ff9bd80b 100644 --- a/futurex_openedx_extensions/dashboard/serializers.py +++ b/futurex_openedx_extensions/dashboard/serializers.py @@ -79,18 +79,20 @@ def get_download_url(self, obj: DataExportTask) -> Any: # pylint: disable=no-se class LearnerBasicDetailsSerializer(ModelSerializerOptionalFields): """Serializer for learner's basic details.""" - user_id = serializers.SerializerMethodField() - full_name = serializers.SerializerMethodField() - alternative_full_name = serializers.SerializerMethodField() - username = serializers.SerializerMethodField() - national_id = serializers.SerializerMethodField() - email = serializers.SerializerMethodField() - mobile_no = serializers.SerializerMethodField() - year_of_birth = serializers.SerializerMethodField() - gender = serializers.SerializerMethodField() - gender_display = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - last_login = serializers.SerializerMethodField() + user_id = serializers.SerializerMethodField(help_text='User ID in edx-platform') + full_name = serializers.SerializerMethodField(help_text='Full name of the user') + alternative_full_name = serializers.SerializerMethodField(help_text='Arabic name (if available)') + username = serializers.SerializerMethodField(help_text='Username of the user in edx-platform') + national_id = serializers.SerializerMethodField(help_text='National ID of the user (if available)') + email = serializers.SerializerMethodField(help_text='Email of the user in edx-platform') + mobile_no = serializers.SerializerMethodField(help_text='Mobile number of the user (if available)') + year_of_birth = serializers.SerializerMethodField(help_text='Year of birth of the user (if available)') + gender = serializers.SerializerMethodField(help_text='Gender code of the user (if available)') + gender_display = serializers.SerializerMethodField(help_text='Gender of the user (if available)') + date_joined = serializers.SerializerMethodField( + help_text='Date when the user was registered in the platform regardless of which tenant', + ) + last_login = serializers.SerializerMethodField(help_text='Date when the user last logged in') class Meta: model = get_user_model() @@ -357,8 +359,8 @@ def _extract_exam_scores(representation_item: dict[str, Any]) -> None: class LearnerDetailsSerializer(LearnerBasicDetailsSerializer): """Serializer for learner details.""" - enrolled_courses_count = serializers.SerializerMethodField() - certificates_count = serializers.SerializerMethodField() + enrolled_courses_count = serializers.SerializerMethodField(help_text='Number of courses the user is enrolled in') + certificates_count = serializers.SerializerMethodField(help_text='Number of certificates the user has earned') class Meta: model = get_user_model() diff --git a/futurex_openedx_extensions/dashboard/views.py b/futurex_openedx_extensions/dashboard/views.py index 9cff4929..a7a6728b 100644 --- a/futurex_openedx_extensions/dashboard/views.py +++ b/futurex_openedx_extensions/dashboard/views.py @@ -12,6 +12,7 @@ from django.http import JsonResponse from django.shortcuts import get_object_or_404 from django_filters.rest_framework import DjangoFilterBackend +from edx_api_doc_tools import exclude_schema_for from rest_framework import status as http_status from rest_framework import viewsets from rest_framework.exceptions import ParseError @@ -27,6 +28,7 @@ get_learners_enrollments_queryset, get_learners_queryset, ) +from futurex_openedx_extensions.dashboard.docs_utils import docs from futurex_openedx_extensions.dashboard.statistics.certificates import get_certificates_count from futurex_openedx_extensions.dashboard.statistics.courses import ( get_courses_count, @@ -73,6 +75,7 @@ default_auth_classes = FX_VIEW_DEFAULT_AUTH_CLASSES.copy() +@docs('TotalCountsView.get') class TotalCountsView(FXViewRoleInfoMixin, APIView): """ View to get the total count statistics @@ -147,17 +150,7 @@ def _get_stat_count(self, stat: str, tenant_id: int, include_staff: bool) -> int return self._get_learners_count_data(one_tenant_permission_info, include_staff) def get(self, request: Any, *args: Any, **kwargs: Any) -> Response | JsonResponse: - """ - GET /api/fx/statistics/v1/total_counts/?stats=&tenant_ids= - - (required): a comma-separated list of the types of count statistics to include in the - response. Available count statistics are: - certificates: total number of issued certificates in the selected tenants - courses: total number of courses in the selected tenants - learners: total number of learners in the selected tenants - (optional): a comma-separated list of the tenant IDs to get the information for. If not provided, - the API will assume the list of all accessible tenants by the user - """ + """Returns the total count statistics for the selected tenants.""" stats = request.query_params.get('stats', '').split(',') invalid_stats = list(set(stats) - set(self.valid_stats)) if invalid_stats: @@ -184,6 +177,7 @@ def get(self, request: Any, *args: Any, **kwargs: Any) -> Response | JsonRespons return JsonResponse(result) +@docs('LearnersView.get') class LearnersView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): """View to get the list of learners""" authentication_classes = default_auth_classes @@ -206,6 +200,7 @@ def get_queryset(self) -> QuerySet: ) +@docs('CoursesView.get') class CoursesView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): """View to get the list of courses""" authentication_classes = default_auth_classes @@ -235,6 +230,7 @@ def get_queryset(self) -> QuerySet: ) +@docs('CourseStatusesView.get') class CourseStatusesView(FXViewRoleInfoMixin, APIView): """View to get the course statuses""" authentication_classes = default_auth_classes @@ -269,6 +265,7 @@ def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: return JsonResponse(self.to_json(result)) +@docs('LearnerInfoView.get') class LearnerInfoView(FXViewRoleInfoMixin, APIView): """View to get the information of a learner""" authentication_classes = default_auth_classes @@ -304,6 +301,9 @@ def get(self, request: Any, username: str, *args: Any, **kwargs: Any) -> JsonRes ) +@docs('DataExportManagementView.list') +@docs('DataExportManagementView.partial_update') +@docs('DataExportManagementView.retrieve') class DataExportManagementView(FXViewRoleInfoMixin, viewsets.ModelViewSet): # pylint: disable=too-many-ancestors """View to list and retrieve data export tasks.""" authentication_classes = default_auth_classes @@ -335,6 +335,7 @@ def get_object(self) -> DataExportTask: return task +@docs('LearnerCoursesView.get') class LearnerCoursesView(FXViewRoleInfoMixin, APIView): """View to get the list of courses for a learner""" authentication_classes = default_auth_classes @@ -372,6 +373,7 @@ def get(self, request: Any, username: str, *args: Any, **kwargs: Any) -> JsonRes ).data) +@docs('VersionInfoView.get') class VersionInfoView(APIView): """View to get the version information""" permission_classes = [IsSystemStaff] @@ -386,6 +388,7 @@ def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: # pylin }) +@exclude_schema_for('get') class AccessibleTenantsInfoView(APIView): """View to get the list of accessible tenants""" permission_classes = [IsAnonymousOrSystemStaff] @@ -407,6 +410,7 @@ def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: # pylin return JsonResponse(get_tenants_info(tenant_ids)) +@docs('LearnersDetailsForCourseView.get') class LearnersDetailsForCourseView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): """View to get the list of learners for a course""" authentication_classes = default_auth_classes @@ -443,6 +447,7 @@ def get_serializer_context(self) -> Dict[str, Any]: return context +@exclude_schema_for('get') class LearnersEnrollmentView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): """View to get the list of learners for a course""" serializer_class = serializers.LearnerEnrollmentSerializer @@ -450,7 +455,7 @@ class LearnersEnrollmentView(ExportCSVMixin, FXViewRoleInfoMixin, ListAPIView): pagination_class = DefaultPagination fx_view_name = 'learners_enrollment_details' fx_default_read_only_roles = ['staff', 'instructor', 'data_researcher', 'org_course_creator_group'] - fx_view_description = 'api/fx/learners/v1/enrollments: Get the list of enrollemts' + fx_view_description = 'api/fx/learners/v1/enrollments: Get the list of enrollments' def get_queryset(self, *args: Any, **kwargs: Any) -> QuerySet: """Get the list of learners for a course""" @@ -484,6 +489,7 @@ def get_serializer_context(self) -> Dict[str, Any]: return context +@docs('GlobalRatingView.get') class GlobalRatingView(FXViewRoleInfoMixin, APIView): """View to get the global rating""" authentication_classes = default_auth_classes @@ -512,6 +518,12 @@ def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: return JsonResponse(result) +@docs('UserRolesManagementView.create') +@docs('UserRolesManagementView.destroy') +@docs('UserRolesManagementView.list') +@docs('UserRolesManagementView.retrieve') +@docs('UserRolesManagementView.update') +@exclude_schema_for('partial_update') class UserRolesManagementView(FXViewRoleInfoMixin, viewsets.ModelViewSet): # pylint: disable=too-many-ancestors """View to get the user roles""" authentication_classes = default_auth_classes @@ -675,6 +687,7 @@ def destroy(self, request: Any, *args: Any, **kwargs: Any) -> Response: return Response(status=http_status.HTTP_204_NO_CONTENT) +@docs('MyRolesView.get') class MyRolesView(FXViewRoleInfoMixin, APIView): """View to get the user roles of the caller""" authentication_classes = default_auth_classes @@ -692,6 +705,7 @@ def get(self, request: Any, *args: Any, **kwargs: Any) -> JsonResponse: return JsonResponse(data) +@exclude_schema_for('get') class ClickhouseQueryView(FXViewRoleInfoMixin, APIView): """View to get the Clickhouse query""" authentication_classes = default_auth_classes diff --git a/futurex_openedx_extensions/helpers/models.py b/futurex_openedx_extensions/helpers/models.py index 7e2056be..344d5360 100644 --- a/futurex_openedx_extensions/helpers/models.py +++ b/futurex_openedx_extensions/helpers/models.py @@ -446,7 +446,7 @@ class DataExportTask(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_IN_QUEUE) progress = models.FloatField(default=0.0) - notes = models.CharField(max_length=255, default='', blank=True) + notes = models.CharField(max_length=255, default='', blank=True, help_text='Optional note for the task') tenant = models.ForeignKey(TenantConfig, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) started_at = models.DateTimeField(null=True, blank=True) diff --git a/requirements/test-constraints-palm.txt b/requirements/test-constraints-palm.txt index 1be91f15..3b38f151 100644 --- a/requirements/test-constraints-palm.txt +++ b/requirements/test-constraints-palm.txt @@ -6,6 +6,7 @@ eox-tenant==v10.0.0 # edx-platform related requirements. Pinned to the versions used in Palm. +edx-api-doc-tools==1.6.0 edx-opaque-keys==2.3.0 edx-lint<5.4.0 django-filter==23.1 diff --git a/requirements/test-constraints-redwood.txt b/requirements/test-constraints-redwood.txt index 7852b7a1..eb8436ca 100644 --- a/requirements/test-constraints-redwood.txt +++ b/requirements/test-constraints-redwood.txt @@ -6,6 +6,7 @@ eox-tenant