diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 323fb9a..de18356 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,8 +12,11 @@ Change Log Unreleased ---------- +Added +----- +* Swagger support alongside REST API documentation -[1.2.0] - 2020-02-03 +[1.2.0] - 2021-02-03 -------------------- Added diff --git a/eox_tagging/api/v1/filters.py b/eox_tagging/api/v1/filters.py index 377424f..b9739a2 100644 --- a/eox_tagging/api/v1/filters.py +++ b/eox_tagging/api/v1/filters.py @@ -1,4 +1,7 @@ """Filter module for tags.""" +import warnings # NOTE: to be removed alongside the backport + +from django_filters import compat # NOTE: to be removed alongside the backport from django_filters import rest_framework as filters from eox_tagging.constants import AccessLevel @@ -83,3 +86,60 @@ def filter_access_type(self, queryset, name, value): # pylint: disable=unused-a queryset = queryset.filter(access=access) if access else queryset.none() return queryset + + +class FilterBackend(filters.DjangoFilterBackend): + """ + Backport this fix (https://github.com/carltongibson/django-filter/pull/1323) + for range type filters. + The current version of django-filter on edx-platform (v2.2.0) presents a bug + were range filters don't produce the correct OpenAPI schema. This schema is + used by our documentation tools (drf-yasg). This backport should be dropped + when a new version of django-filter with the fix is released (probably v2.5.0) + """ + + def get_schema_fields(self, view): + # This is not compatible with widgets where the query param differs from the + # filter's attribute name. Notably, this includes `MultiWidget`, where query + # params will be of the format `_0`, `_1`, etc... + assert compat.coreapi is not None, 'coreapi must be installed to use `get_schema_fields()`' + assert compat.coreschema is not None, 'coreschema must be installed to use `get_schema_fields()`' + try: + queryset = view.get_queryset() + except Exception: # pylint: disable=broad-except + queryset = None + warnings.warn( + "{} is not compatible with schema generation".format(view.__class__) + ) + + filterset_class = self.get_filterset_class(view, queryset) + if not filterset_class: + return [] + + return [self.build_coreapi_field(schema_field_name, field) + for field_name, field in filterset_class.base_filters.items() + for schema_field_name in self.get_schema_field_names(field_name, field) + ] + + def build_coreapi_field(self, name, field): # pylint: disable=missing-function-docstring + return compat.coreapi.Field( + name=name, + required=field.extra['required'], + location='query', + schema=self.get_coreschema_field(field), + ) + + def get_schema_field_names(self, field_name, field): + """ + Get the corresponding schema field names required to generate the openAPI schema + by referencing the widget suffixes if available. + """ + try: + suffixes = field.field_class.widget.suffixes + except AttributeError: + return [field_name] + else: + return [field_name] if not suffixes else [ + '{}_{}'.format(field_name, suffix) + for suffix in suffixes if suffix + ] diff --git a/eox_tagging/api/v1/viewset.py b/eox_tagging/api/v1/viewset.py index 0fa2d1d..af44494 100644 --- a/eox_tagging/api/v1/viewset.py +++ b/eox_tagging/api/v1/viewset.py @@ -1,12 +1,12 @@ """ Viewset for Tags. """ -from django_filters import rest_framework as filters +from edx_api_doc_tools import query_parameter, schema_for from eox_audit_model.decorators import audit_method -from rest_framework import viewsets +from rest_framework import status, viewsets from rest_framework.authentication import SessionAuthentication -from eox_tagging.api.v1.filters import TagFilter +from eox_tagging.api.v1.filters import FilterBackend, TagFilter from eox_tagging.api.v1.pagination import TagApiPagination from eox_tagging.api.v1.permissions import EoxTaggingAPIPermission from eox_tagging.api.v1.serializers import TagSerializer @@ -15,6 +15,165 @@ from eox_tagging.models import Tag +@schema_for( + "create", + """ + Creates a tag for a given object + There are three different types of objects that can be labeled with a tag: courses,\ + users and enrollments. The type of objects that can be labeled (and extra\ + validations for different fields) are defined in the configuration of the\ + site. + + **Example Request** + + POST /eox-tagging/api/v1/tags/ + { + "tag_type": "subscription_level", + "tag_value": "premium", + "target_type": "courseoverview", + "target_id": "course-v1:edX+DemoX+Demo_Course", + "access": "PUBLIC", + "owner_type": "site" + } + + **Parameters** + + - `tag_type` (**required**, string, _body_): + General category for the tag (i.e subscription_level). This value is set in\ + the site configuration. + + - `tag_value` (**required**, string, _body_): + An item of the category (i.e premium). If there isn't a validation in the\ + site configuration it can take any string. + + - `target_type` (**required**, string, _body_): + One of courseoverview, user, courseenrollment + + - `target_id` (**required**, string, _body_): Identifier of the target\ + object. For users, username; for courseoverview, course_id and for\ + courseenrollments a string with the following format: "`username:\ + course_id`" + + - `activation_date` (**optional**, string, _body_): + DateTime format `YYYY-MM-DD HH:MM:SS`. + + - `expiration_date` (**optional**, string, _body_): + DateTime format `YYYY-MM-DD HH:MM:SS`. + + - `owner_type` (**optional**, string, _body_): + Owner of the tag, either `site` or `user` + + - `access` (**optional**, string, _body_): + Visibility of the tag, either `PUBLIC`, `PRIVATE` or `PROTECTED` + """, +) +@schema_for( + "destroy", + """ + Delete single tag by key. Deleted tags are inactivated (soft delete) + """, + responses={status.HTTP_404_NOT_FOUND: "Not found"}, +) +@schema_for( + "retrieve", + """ + Fetch details for a single tag by key + """, + responses={status.HTTP_404_NOT_FOUND: "Not found"}, +) +@schema_for( + "list", + """ + Fetch a list of tags. + + The list can be narrowed using the available filters. + - Some filters are incompatible with each other,\ + namely `course_id`, `username` and `target_type`. The reason being that `course_id` and `username`\ + have an implicit `target_type` of `courseoverview` and `user`. + - DateTime filters must have the following format `YY-MM-DD HH:MM:SS`. Time is optional, date is not.\ + Time must be UTC. + - Parameters not defined bellow will be ignored. If you apply a filter with a typo you'll get the \ + whole list of tags. + """, + parameters=[ + query_parameter( + "key", + str, + "The unique identifier. Same as `GET /eox-tagging/api/v1/tags/{key}`", + ), + query_parameter( + "status", str, "Filter active or inactive tags. Default: active" + ), + query_parameter( + "include_inactive", bool, "If true include the inactive tags on the list. Default false" + ), + query_parameter( + "tag_type", + str, + "The type of the tag, set on the configuration of the site (i.e. Subscription level)", + ), + query_parameter("tag_value", str, "The value of the tag (i.e. Premium)"), + query_parameter( + "course_id", + str, + "Shortcut to filter objects of target_type `courseoverview` with id `course_id`.", + ), + query_parameter( + "username", + str, + "Shortcut to filter objects of target_type `user` with id `username`.", + ), + query_parameter( + "target_type", + str, + "The type of the object that was tagged, one of: `course`, `courseenrollment`, `user`", + ), + query_parameter( + "enrollment_username", + str, + "User identifier (username) to be used when target_type=courseenrollment. " + "Can be omitted and is ignored for a different target_type", + ), + query_parameter( + "enrollment_course_id", + str, + "Course identifier to be used when target_type=courseenrollment." + "Can be omitted and is ignored for a different target_type", + ), + query_parameter( + "created_at_before", + str, + "Filter tags created before date. Format `YY-MM-DD HH:MM:SS`", + ), + query_parameter( + "created_at_after", + str, + "Filter tags created after date. Format `YY-MM-DD HH:MM:SS`", + ), + query_parameter( + "activation_date_before", + str, + "Filter tags created before date. Format `YY-MM-DD HH:MM:SS`", + ), + query_parameter( + "activation_date_after", + str, + "Filter tags created after date. Format `YY-MM-DD HH:MM:SS`", + ), + query_parameter( + "expiration_date_before", + str, + "Filter tags created before date. Format `YY-MM-DD HH:MM:SS`", + ), + query_parameter( + "expiration_date_after", + str, + "Filter tags created after date. Format `YY-MM-DD HH:MM:SS`", + ), + query_parameter("access", str, "Filter by access, One of `PUBLIC`, `PRIVATE`, `PROTECTED`"), + ], + responses={status.HTTP_404_NOT_FOUND: "Not found"}, +) class TagViewSet(viewsets.ModelViewSet): """Viewset for listing and creating Tags.""" @@ -22,7 +181,7 @@ class TagViewSet(viewsets.ModelViewSet): authentication_classes = (BearerAuthentication, SessionAuthentication) permission_classes = (EoxTaggingAPIPermission,) pagination_class = TagApiPagination - filter_backends = (filters.DjangoFilterBackend,) + filter_backends = (FilterBackend,) filter_class = TagFilter lookup_field = "key" http_method_names = ["get", "post", "delete", "head"] @@ -38,7 +197,7 @@ def get_queryset(self): return queryset def create(self, request, *args, **kwargs): - """"Hijack the create method and use a wrapper function to perform the + """Hijack the create method and use a wrapper function to perform the audit process. The original parameters of create are not very useful in raw form, this way we pass more useful information to our wrapper function to be audited @@ -51,7 +210,7 @@ def audited_create(headers, body): # pylint: disable=unused-argument return audited_create(headers=request.headers, body=request.data) def destroy(self, request, *args, **kwargs): - """"Hijack the destroy method and use a wrapper function to perform the + """Hijack the destroy method and use a wrapper function to perform the audit process. The original parameters of destroy are not very useful in raw form, this way we pass more useful information to our wrapper function to be audited diff --git a/eox_tagging/api_schema.py b/eox_tagging/api_schema.py new file mode 100644 index 0000000..c5f0f36 --- /dev/null +++ b/eox_tagging/api_schema.py @@ -0,0 +1,63 @@ +""" +Swagger view generator +""" +from django.conf import settings +from django.conf.urls import include, url +from django.urls import reverse +from drf_yasg.generators import OpenAPISchemaGenerator +from drf_yasg.openapi import SwaggerDict +from drf_yasg.views import get_schema_view +from edx_api_doc_tools import get_docs_cache_timeout, internal_utils, make_api_info +from rest_framework import permissions + + +class APISchemaGenerator(OpenAPISchemaGenerator): + """ + Schema generator for eox-core. + + Define specific security definition using oauth without overwritting project wide + settings. + """ + + def get_security_definitions(self): + security_definitions = { + "OAuth2": { + "flow": "application", + "tokenUrl": "{}{}".format(settings.LMS_ROOT_URL, reverse('access_token')), + "type": "oauth2", + }, + } + security_definitions = SwaggerDict.as_odict(security_definitions) + return security_definitions + + +api_urls = [ + url(r"eox-tagging/api/", include("eox_tagging.api.urls")) +] + +api_info = make_api_info( + title="eox tagging", + version="v1", + email=" contact@edunext.co", + description=internal_utils.dedent("""\ + eox tagging REST API + + eox Tagging provides the ability to apply a simple label to certain objects \ + (courses, enrollments and users). The label or tag includes a timestamp for \ + when the tag is should be considered active, as well as fields to include \ + the general category of the tag (tag_type) and a value belonging to that \ + category (tag_value). + + eox tagging is meant to be a lightweight plugin with emphasis on flexibility\ + most of the logic regarding the deactivation of tags at a given time must be\ + handled separately. + """), +) + +docs_ui_view = get_schema_view( + api_info, + generator_class=APISchemaGenerator, + public=True, + permission_classes=[permissions.AllowAny], + patterns=api_urls, +).with_ui("swagger", cache_timeout=get_docs_cache_timeout()) diff --git a/eox_tagging/urls.py b/eox_tagging/urls.py index d5f9a26..9da80eb 100644 --- a/eox_tagging/urls.py +++ b/eox_tagging/urls.py @@ -4,8 +4,10 @@ from django.conf.urls import include, url from eox_tagging import views +from eox_tagging.api_schema import docs_ui_view urlpatterns = [ url(r'^eox-info$', views.info_view, name='eox-info'), url(r'api/', include('eox_tagging.api.urls')), + url(r'^api-docs/$', docs_ui_view, name='apidocs-ui'), ]