Skip to content

Commit

Permalink
feat: swagger docs
Browse files Browse the repository at this point in the history
  • Loading branch information
shadinaif committed Dec 19, 2024
1 parent 0b8784c commit fbdb659
Show file tree
Hide file tree
Showing 10 changed files with 719 additions and 28 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.19'
__version__ = '0.9.20'
515 changes: 515 additions & 0 deletions futurex_openedx_extensions/dashboard/docs_src.py

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions futurex_openedx_extensions/dashboard/docs_utils.py
Original file line number Diff line number Diff line change
@@ -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
30 changes: 16 additions & 14 deletions futurex_openedx_extensions/dashboard/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
38 changes: 26 additions & 12 deletions futurex_openedx_extensions/dashboard/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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=<countTypesList>&tenant_ids=<tenantIds>
<countTypesList> (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
<tenantIds> (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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -443,14 +447,15 @@ 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
permission_classes = [FXHasTenantCourseAccess]
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"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion futurex_openedx_extensions/helpers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions requirements/test-constraints-palm.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements/test-constraints-redwood.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
eox-tenant<v12.0.0

# edx-platform related requirements. Pinned to the versions used in Redwood.
edx-api-doc-tools==1.8.0
edx-opaque-keys==2.9.0
edx-lint==5.3.6
django-filter==24.2
Expand Down
1 change: 1 addition & 0 deletions requirements/test.in
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pytest-django # pytest extension for better Django support
eox-tenant

# edx-platform related requirements. Pinned to the versions used in Palm.
edx-api-doc-tools
edx-opaque-keys[django]
djangorestframework
django-filter
Expand Down
Loading

0 comments on commit fbdb659

Please sign in to comment.