feat: Unit page API as DRF

This commit is contained in:
ruzniaievdm
2024-01-10 11:31:20 +02:00
parent 2eabfe10c0
commit ff8d3eae72
11 changed files with 459 additions and 112 deletions

View File

@@ -6,7 +6,7 @@ from .course_rerun import CourseRerunSerializer
from .course_team import CourseTeamSerializer
from .course_index import CourseIndexSerializer
from .grading import CourseGradingModelSerializer, CourseGradingSerializer
from .home import CourseHomeSerializer, CourseTabSerializer, LibraryTabSerializer
from .home import CourseHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer
from .proctoring import (
LimitedProctoredExamSettingsSerializer,
ProctoredExamConfigurationSerializer,
@@ -21,3 +21,4 @@ from .videos import (
VideoUsageSerializer,
VideoDownloadSerializer
)
from .vertical_block import ContainerHandlerSerializer

View File

@@ -31,7 +31,7 @@ class LibraryViewSerializer(serializers.Serializer):
can_edit = serializers.BooleanField()
class CourseTabSerializer(serializers.Serializer):
class CourseHomeTabSerializer(serializers.Serializer):
archived_courses = CourseCommonSerializer(required=False, many=True)
courses = CourseCommonSerializer(required=False, many=True)
in_process_course_actions = UnsucceededCourseSerializer(many=True, required=False, allow_null=True)

View File

@@ -0,0 +1,92 @@
"""
API Serializers for unit page
"""
from django.urls import reverse
from rest_framework import serializers
from cms.djangoapps.contentstore.helpers import (
xblock_studio_url,
xblock_type_display_name,
)
class ChildAncestorSerializer(serializers.Serializer):
"""
Serializer for representing child blocks in the ancestor XBlock.
"""
url = serializers.SerializerMethodField()
display_name = serializers.CharField(source="display_name_with_default")
def get_url(self, obj):
"""
Method to generate studio URL for the child block.
"""
return xblock_studio_url(obj)
class AncestorXBlockSerializer(serializers.Serializer):
"""
Serializer for representing the ancestor XBlock and its children.
"""
children = ChildAncestorSerializer(many=True)
title = serializers.CharField()
is_last = serializers.BooleanField()
class ContainerXBlock(serializers.Serializer):
"""
Serializer for representing XBlock data. Doesn't include all data about XBlock.
"""
display_name = serializers.CharField(source="display_name_with_default")
display_type = serializers.SerializerMethodField()
category = serializers.CharField()
def get_display_type(self, obj):
"""
Method to get the display type name for the container XBlock.
"""
return xblock_type_display_name(obj)
class ContainerHandlerSerializer(serializers.Serializer):
"""
Serializer for container handler
"""
language_code = serializers.CharField()
action = serializers.CharField()
xblock = ContainerXBlock()
is_unit_page = serializers.BooleanField()
is_collapsible = serializers.BooleanField()
position = serializers.IntegerField(min_value=1)
prev_url = serializers.CharField(allow_null=True)
next_url = serializers.CharField(allow_null=True)
new_unit_category = serializers.CharField()
outline_url = serializers.CharField()
ancestor_xblocks = AncestorXBlockSerializer(many=True)
component_templates = serializers.ListField(child=serializers.DictField())
xblock_info = serializers.DictField()
draft_preview_link = serializers.CharField()
published_preview_link = serializers.CharField()
show_unit_tags = serializers.BooleanField()
user_clipboard = serializers.DictField()
is_fullwidth_content = serializers.BooleanField()
assets_url = serializers.SerializerMethodField()
unit_block_id = serializers.CharField(source="unit.location.block_id")
subsection_location = serializers.CharField(source="subsection.location")
def get_assets_url(self, obj):
"""
Method to get the assets URL based on the course id.
"""
context_course = obj.get("context_course", None)
if context_course:
return reverse(
"assets_handler", kwargs={"course_key_string": context_course.id}
)
return None

View File

@@ -1,10 +1,12 @@
""" Contenstore API v1 URLs. """
from django.conf import settings
from django.urls import re_path, path
from openedx.core.constants import COURSE_ID_PATTERN
from .views import (
ContainerHandlerView,
CourseDetailsView,
CourseTeamView,
CourseIndexView,
@@ -100,6 +102,11 @@ urlpatterns = [
CourseRerunView.as_view(),
name="course_rerun"
),
re_path(
fr'^container_handler/{settings.USAGE_KEY_PATTERN}$',
ContainerHandlerView.as_view(),
name="container_handler"
),
# Authoring API
# Do not use under v1 yet (Nov. 23). The Authoring API is still experimental and the v0 versions should be used

View File

@@ -15,3 +15,4 @@ from .videos import (
VideoDownloadView
)
from .help_urls import HelpUrlsView
from .vertical_block import ContainerHandlerView

View File

@@ -8,7 +8,7 @@ from rest_framework.views import APIView
from openedx.core.lib.api.view_utils import view_auth_classes
from ....utils import get_home_context, get_course_context, get_library_context
from ..serializers import CourseHomeSerializer, CourseTabSerializer, LibraryTabSerializer
from ..serializers import CourseHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer
@view_auth_classes(is_authenticated=True)
@@ -102,7 +102,7 @@ class HomePageCoursesView(APIView):
description="Query param to filter by course org",
)],
responses={
200: CourseTabSerializer,
200: CourseHomeTabSerializer,
401: "The requester is not authenticated.",
},
)
@@ -160,7 +160,7 @@ class HomePageCoursesView(APIView):
"archived_courses": archived_courses,
"in_process_course_actions": in_process_course_actions,
}
serializer = CourseTabSerializer(courses_context)
serializer = CourseHomeTabSerializer(courses_context)
return Response(serializer.data)

View File

@@ -0,0 +1,67 @@
"""
Unit tests for the vertical block.
"""
from django.urls import reverse
from rest_framework import status
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
class ContainerHandlerViewTest(CourseTestCase):
"""
Unit tests for the ContainerHandlerView.
"""
def setUp(self):
super().setUp()
self.chapter = BlockFactory.create(
parent=self.course, category="chapter", display_name="Week 1"
)
self.sequential = BlockFactory.create(
parent=self.chapter, category="sequential", display_name="Lesson 1"
)
self.vertical = self._create_block(self.sequential, "vertical", "Unit")
self.store = modulestore()
self.store.publish(self.vertical.location, self.user.id)
def _get_reverse_url(self, location):
"""
Creates url to current handler view api
"""
return reverse(
"cms.djangoapps.contentstore:v1:container_handler",
kwargs={"usage_key_string": location},
)
def _create_block(self, parent, category, display_name, **kwargs):
"""
Creates a block without publishing it.
"""
return BlockFactory.create(
parent=parent,
category=category,
display_name=display_name,
publish_item=False,
user_id=self.user.id,
**kwargs
)
def test_success_response(self):
"""
Check that endpoint is valid and success response.
"""
url = self._get_reverse_url(self.vertical.location)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_not_valid_usage_key_string(self):
"""
Check that invalid 'usage_key_string' raises Http404.
"""
usage_key_string = "i4x://InvalidOrg/InvalidCourse/vertical/static/InvalidContent"
url = self._get_reverse_url(usage_key_string)
response = self.client.get(url)
self.assertEqual(response.status_code, 404)

View File

@@ -0,0 +1,143 @@
""" API Views for unit page """
import edx_api_doc_tools as apidocs
from django.http import Http404, HttpResponseBadRequest
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from cms.djangoapps.contentstore.utils import get_container_handler_context
from cms.djangoapps.contentstore.views.component import _get_item_in_course
from cms.djangoapps.contentstore.rest_api.v1.serializers import ContainerHandlerSerializer
from openedx.core.lib.api.view_utils import view_auth_classes
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
@view_auth_classes(is_authenticated=True)
class ContainerHandlerView(APIView):
"""
View for container xblock requests to get vertical data.
"""
def get_object(self, usage_key_string):
"""
Get an object by usage-id of the block
"""
try:
usage_key = UsageKey.from_string(usage_key_string)
except InvalidKeyError:
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
return usage_key
@apidocs.schema(
parameters=[
apidocs.string_parameter(
"usage_key_string",
apidocs.ParameterLocation.PATH,
description="Container usage key",
),
],
responses={
200: ContainerHandlerSerializer,
401: "The requester is not authenticated.",
404: "The requested locator does not exist.",
},
)
def get(self, request: Request, usage_key_string: str):
"""
Get an object containing vertical data.
**Example Request**
GET /api/contentstore/v1/container_handler/{usage_key_string}
**Response Values**
If the request is successful, an HTTP 200 "OK" response is returned.
The HTTP 200 response contains a single dict that contains keys that
are the vertical's container data.
**Example Response**
```json
{
"language_code": "zh-cn",
"action": "view",
"xblock": {
"display_name": "Labs and Demos",
"display_type": "单元",
"category": "vertical"
},
"is_unit_page": true,
"is_collapsible": false,
"position": 1,
"prev_url": "block-v1-edX%2BDemo_Course%2Btype%40vertical%2Bblock%404e592689563243c484",
"next_url": "block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40vertical%2Bblock%40vertical_aae927868e55",
"new_unit_category": "vertical",
"outline_url": "/course/course-v1:edX+DemoX+Demo_Course?format=concise",
"ancestor_xblocks": [
{
"children": [
{
"url": "/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%",
"display_name": "Introduction"
},
...
],
"title": "Example Week 2: Get Interactive",
"is_last": false
},
...
],
"component_templates": [
{
"type": "advanced",
"templates": [
{
"display_name": "批注",
"category": "annotatable",
"boilerplate_name": null,
"hinted": false,
"tab": "common",
"support_level": true
},
...
},
...
],
"xblock_info": {},
"draft_preview_link": "//preview.localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/...",
"published_preview_link": "///courses/course-v1:edX+DemoX+Demo_Course/jump_to/...",
"show_unit_tags": false,
"user_clipboard": {
"content": null,
"source_usage_key": "",
"source_context_title": "",
"source_edit_url": ""
},
"is_fullwidth_content": false,
"assets_url": "/assets/course-v1:edX+DemoX+Demo_Course/",
"unit_block_id": "d6cee45205a449369d7ef8f159b22bdf",
"subsection_location": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations"
}
```
"""
usage_key = self.get_object(usage_key_string)
course_key = usage_key.course_key
with modulestore().bulk_operations(course_key):
try:
course, xblock, lms_link, preview_lms_link = _get_item_in_course(request, usage_key)
except ItemNotFoundError:
return HttpResponseBadRequest()
context = get_container_handler_context(request, usage_key, course, xblock)
context.update({
'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link,
})
serializer = ContainerHandlerSerializer(context)
return Response(serializer.data)

View File

@@ -7,6 +7,7 @@ import logging
from collections import defaultdict
from contextlib import contextmanager
from datetime import datetime, timezone
from urllib.parse import quote_plus
from uuid import uuid4
from django.conf import settings
@@ -1784,6 +1785,129 @@ def _get_course_index_context(request, course_key, course_block):
return course_index_context
def get_container_handler_context(request, usage_key, course, xblock): # pylint: disable=too-many-statements
"""
Utils is used to get context for container xblock requests.
It is used for both DRF and django views.
"""
from cms.djangoapps.contentstore.views.component import (
get_component_templates,
get_unit_tags,
CONTAINER_TEMPLATES,
LIBRARY_BLOCK_TYPES,
)
from cms.djangoapps.contentstore.helpers import get_parent_xblock, is_unit
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
add_container_page_publishing_info,
create_xblock_info,
)
from openedx.core.djangoapps.content_staging import api as content_staging_api
component_templates = get_component_templates(course)
ancestor_xblocks = []
parent = get_parent_xblock(xblock)
action = request.GET.get('action', 'view')
is_unit_page = is_unit(xblock)
unit = xblock if is_unit_page else None
is_first = True
block = xblock
# Build the breadcrumbs and find the ``Unit`` ancestor
# if it is not the immediate parent.
while parent:
if unit is None and is_unit(block):
unit = block
# add all to nav except current xblock page
if xblock != block:
current_block = {
'title': block.display_name_with_default,
'children': parent.get_children(),
'is_last': is_first
}
is_first = False
ancestor_xblocks.append(current_block)
block = parent
parent = get_parent_xblock(parent)
ancestor_xblocks.reverse()
if unit is None:
raise ValueError("Could not determine unit page")
subsection = get_parent_xblock(unit)
if subsection is None:
raise ValueError(f"Could not determine parent subsection from unit {unit.location}")
section = get_parent_xblock(subsection)
if section is None:
raise ValueError(f"Could not determine ancestor section from unit {unit.location}")
# for the sequence navigator
prev_url, next_url = get_sibling_urls(subsection, unit.location)
# these are quoted here because they'll end up in a query string on the page,
# and quoting with mako will trigger the xss linter...
prev_url = quote_plus(prev_url) if prev_url else None
next_url = quote_plus(next_url) if next_url else None
show_unit_tags = use_tagging_taxonomy_list_page()
unit_tags = None
if show_unit_tags and is_unit_page:
unit_tags = get_unit_tags(usage_key)
# Fetch the XBlock info for use by the container page. Note that it includes information
# about the block's ancestors and siblings for use by the Unit Outline.
xblock_info = create_xblock_info(xblock, include_ancestor_info=is_unit_page, tags=unit_tags)
if is_unit_page:
add_container_page_publishing_info(xblock, xblock_info)
# need to figure out where this item is in the list of children as the
# preview will need this
index = 1
for child in subsection.get_children():
if child.location == unit.location:
break
index += 1
# Get the status of the user's clipboard so they can paste components if they have something to paste
user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request)
library_block_types = [problem_type['component'] for problem_type in LIBRARY_BLOCK_TYPES]
is_library_xblock = xblock.location.block_type in library_block_types
context = {
'language_code': request.LANGUAGE_CODE,
'context_course': course, # Needed only for display of menus at top of page.
'action': action,
'xblock': xblock,
'xblock_locator': xblock.location,
'unit': unit,
'is_unit_page': is_unit_page,
'is_collapsible': is_library_xblock,
'subsection': subsection,
'section': section,
'position': index,
'prev_url': prev_url,
'next_url': next_url,
'new_unit_category': 'vertical',
'outline_url': '{url}?format=concise'.format(url=reverse_course_url('course_handler', course.id)),
'ancestor_xblocks': ancestor_xblocks,
'component_templates': component_templates,
'xblock_info': xblock_info,
'templates': CONTAINER_TEMPLATES,
'show_unit_tags': show_unit_tags,
# Status of the user's clipboard, exactly as would be returned from the "GET clipboard" REST API.
'user_clipboard': user_clipboard,
'is_fullwidth_content': is_library_xblock,
}
return context
class StudioPermissionsService:
"""
Service that can provide information about a user's permissions.

View File

@@ -4,7 +4,6 @@ Studio component views
import logging
from urllib.parse import quote_plus
from django.conf import settings
from django.contrib.auth.decorators import login_required
@@ -25,24 +24,14 @@ from common.djangoapps.edxmako.shortcuts import render_to_response
from common.djangoapps.student.auth import has_course_author_access
from common.djangoapps.xblock_django.api import authorable_xblocks, disabled_xblocks
from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag
from cms.djangoapps.contentstore.toggles import (
use_new_problem_editor,
use_tagging_taxonomy_list_page,
)
from cms.djangoapps.contentstore.helpers import is_unit
from cms.djangoapps.contentstore.toggles import use_new_problem_editor, use_new_unit_page
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import load_services_for_studio
from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
from openedx.core.djangoapps.content_staging import api as content_staging_api
from openedx.core.djangoapps.content_tagging.api import get_content_tags
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
from ..toggles import use_new_unit_page
from ..utils import get_lms_link_for_item, get_sibling_urls, reverse_course_url, get_unit_url
from ..helpers import get_parent_xblock, is_unit, xblock_type_display_name
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
add_container_page_publishing_info,
create_xblock_info,
load_services_for_studio,
)
__all__ = [
'container_handler',
@@ -121,6 +110,9 @@ def container_handler(request, usage_key_string): # pylint: disable=too-many-st
html: returns the HTML page for editing a container
json: not currently supported
"""
from ..utils import get_container_handler_context, get_unit_url
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
try:
@@ -132,10 +124,6 @@ def container_handler(request, usage_key_string): # pylint: disable=too-many-st
course, xblock, lms_link, preview_lms_link = _get_item_in_course(request, usage_key)
except ItemNotFoundError:
return HttpResponseBadRequest()
component_templates = get_component_templates(course)
ancestor_xblocks = []
parent = get_parent_xblock(xblock)
action = request.GET.get('action', 'view')
is_unit_page = is_unit(xblock)
unit = xblock if is_unit_page else None
@@ -143,97 +131,12 @@ def container_handler(request, usage_key_string): # pylint: disable=too-many-st
if is_unit_page and use_new_unit_page(course.id):
return redirect(get_unit_url(course.id, unit.location))
is_first = True
block = xblock
# Build the breadcrumbs and find the ``Unit`` ancestor
# if it is not the immediate parent.
while parent:
if unit is None and is_unit(block):
unit = block
# add all to nav except current xblock page
if xblock != block:
current_block = {
'title': block.display_name_with_default,
'children': parent.get_children(),
'is_last': is_first
}
is_first = False
ancestor_xblocks.append(current_block)
block = parent
parent = get_parent_xblock(parent)
ancestor_xblocks.reverse()
assert unit is not None, "Could not determine unit page"
subsection = get_parent_xblock(unit)
assert subsection is not None, "Could not determine parent subsection from unit " + str(
unit.location)
section = get_parent_xblock(subsection)
assert section is not None, "Could not determine ancestor section from unit " + str(unit.location)
# for the sequence navigator
prev_url, next_url = get_sibling_urls(subsection, unit.location)
# these are quoted here because they'll end up in a query string on the page,
# and quoting with mako will trigger the xss linter...
prev_url = quote_plus(prev_url) if prev_url else None
next_url = quote_plus(next_url) if next_url else None
show_unit_tags = use_tagging_taxonomy_list_page()
unit_tags = None
if show_unit_tags and is_unit_page:
unit_tags = get_unit_tags(usage_key)
# Fetch the XBlock info for use by the container page. Note that it includes information
# about the block's ancestors and siblings for use by the Unit Outline.
xblock_info = create_xblock_info(xblock, include_ancestor_info=is_unit_page, tags=unit_tags)
if is_unit_page:
add_container_page_publishing_info(xblock, xblock_info)
# need to figure out where this item is in the list of children as the
# preview will need this
index = 1
for child in subsection.get_children():
if child.location == unit.location:
break
index += 1
# Get the status of the user's clipboard so they can paste components if they have something to paste
user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request)
library_block_types = [problem_type['component'] for problem_type in LIBRARY_BLOCK_TYPES]
is_library_xblock = xblock.location.block_type in library_block_types
return render_to_response('container.html', {
'language_code': request.LANGUAGE_CODE,
'context_course': course, # Needed only for display of menus at top of page.
'action': action,
'xblock': xblock,
'xblock_locator': xblock.location,
'unit': unit,
'is_unit_page': is_unit_page,
'is_collapsible': is_library_xblock,
'subsection': subsection,
'section': section,
'position': index,
'prev_url': prev_url,
'next_url': next_url,
'new_unit_category': 'vertical',
'outline_url': '{url}?format=concise'.format(url=reverse_course_url('course_handler', course.id)),
'ancestor_xblocks': ancestor_xblocks,
'component_templates': component_templates,
'xblock_info': xblock_info,
container_handler_context = get_container_handler_context(request, usage_key, course, xblock)
container_handler_context.update({
'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link,
'templates': CONTAINER_TEMPLATES,
'show_unit_tags': show_unit_tags,
# Status of the user's clipboard, exactly as would be returned from the "GET clipboard" REST API.
'user_clipboard': user_clipboard,
'is_fullwidth_content': is_library_xblock,
})
return render_to_response('container.html', container_handler_context)
else:
return HttpResponseBadRequest("Only supports HTML requests")
@@ -242,6 +145,9 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
"""
Returns the applicable component templates that can be used by the specified course or library.
"""
from ..helpers import xblock_type_display_name
def create_template_dict(name, category, support_level, boilerplate_name=None, tab="common", hinted=False):
"""
Creates a component template dict.
@@ -545,6 +451,9 @@ def _get_item_in_course(request, usage_key):
Verifies that the caller has permission to access this item.
"""
from ..utils import get_lms_link_for_item
# usage_key's course_key may have an empty run property
usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key))

View File

@@ -3,7 +3,6 @@ Serializers for the content libraries REST API
"""
from rest_framework import serializers
from cms.djangoapps.contentstore.helpers import xblock_studio_url, xblock_type_display_name
from common.djangoapps.student.auth import has_studio_read_access
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
@@ -34,6 +33,8 @@ class StagedContentSerializer(serializers.ModelSerializer):
def get_block_type_display(self, obj):
""" Get the friendly name for this XBlock/component type """
from cms.djangoapps.contentstore.helpers import xblock_type_display_name
return xblock_type_display_name(obj.block_type)
@@ -50,6 +51,8 @@ class UserClipboardSerializer(serializers.Serializer):
def get_source_edit_url(self, obj) -> str:
""" Get the URL where the user can edit the given XBlock, if it exists """
from cms.djangoapps.contentstore.helpers import xblock_studio_url
request = self.context.get("request", None)
user = request.user if request else None
if not user: