Merge branch 'master' into iamsobanjaved/django-42-lts

This commit is contained in:
Awais Qureshi
2024-01-31 13:46:58 +05:00
committed by GitHub
30 changed files with 634 additions and 247 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
@@ -1786,6 +1787,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

@@ -165,3 +165,19 @@ class CertificatesViewsSiteTests(ModuleStoreTestCase):
response,
'This should not survive being overwritten by static content',
)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED, GOOGLE_ANALYTICS_4_ID='GA-abc')
@with_site_configuration(configuration={'platform_name': 'My Platform Site'})
def test_html_view_with_g4(self):
test_url = get_certificate_url(
user_id=self.user.id,
course_id=str(self.course.id),
uuid=self.cert.verify_uuid
)
self._add_course_certificates(count=1, signatory_count=2)
response = self.client.get(test_url)
self.assertContains(
response,
'awarded this My Platform Site Honor Code Certificate of Completion',
)
self.assertContains(response, 'googletagmanager')

View File

@@ -68,6 +68,10 @@ from openedx.core.djangoapps.theming.helpers_dirs import (
from openedx.core.lib.derived import derived, derived_collection_entry
from openedx.core.release import doc_version
from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin
try:
from skill_tagging.skill_tagging_mixin import SkillTaggingMixin
except ImportError:
SkillTaggingMixin = None
################################### FEATURES ###################################
# .. setting_name: PLATFORM_NAME
@@ -1633,6 +1637,8 @@ from xmodule.x_module import XModuleMixin # lint-amnesty, pylint: disable=wrong
# This should be moved into an XBlock Runtime/Application object
# once the responsibility of XBlock creation is moved out of modulestore - cpennington
XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin, EditInfoMixin)
if SkillTaggingMixin:
XBLOCK_MIXINS += (SkillTaggingMixin,)
XBLOCK_EXTRA_MIXINS = ()
# .. setting_name: XBLOCK_FIELD_DATA_WRAPPERS
@@ -5472,3 +5478,31 @@ derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.learning.cert
derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.learning.certificate.revoked.v1',
'learning-certificate-lifecycle', 'enabled')
BEAMER_PRODUCT_ID = ""
#### Survey Report ####
# .. toggle_name: SURVEY_REPORT_ENABLE
# .. toggle_implementation: DjangoSetting
# .. toggle_default: True
# .. toggle_description: Set to True to enable the feature to generate and send survey reports.
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2024-01-30
SURVEY_REPORT_ENABLE = True
# .. setting_name: SURVEY_REPORT_ENDPOINT
# .. setting_default: Open edX organization endpoint
# .. setting_description: Endpoint where the report will be sent.
SURVEY_REPORT_ENDPOINT = 'https://hooks.zapier.com/hooks/catch/11595998/3ouwv7m/'
# .. toggle_name: ANONYMOUS_SURVEY_REPORT
# .. toggle_implementation: DjangoSetting
# .. toggle_default: False
# .. toggle_description: If enable, the survey report will be send a UUID as ID instead of use lms site name.
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2023-02-21
ANONYMOUS_SURVEY_REPORT = False
# .. setting_name: SURVEY_REPORT_CHECK_THRESHOLD
# .. setting_default: every 6 months
# .. setting_description: Survey report banner will appear if a survey report is not sent in the months defined.
SURVEY_REPORT_CHECK_THRESHOLD = 6
# .. setting_name: SURVEY_REPORT_EXTRA_DATA
# .. setting_default: empty dictionary
# .. setting_description: Dictionary with additional information that you want to share in the report.
SURVEY_REPORT_EXTRA_DATA = {}

View File

@@ -1125,14 +1125,6 @@ COURSE_LIVE_GLOBAL_CREDENTIALS["BIG_BLUE_BUTTON"] = {
"URL": ENV_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_URL', None),
}
############## Settings for survey report ##############
SURVEY_REPORT_EXTRA_DATA = ENV_TOKENS.get('SURVEY_REPORT_EXTRA_DATA', {})
SURVEY_REPORT_ENDPOINT = ENV_TOKENS.get('SURVEY_REPORT_ENDPOINT',
'https://hooks.zapier.com/hooks/catch/11595998/3ouwv7m/')
ANONYMOUS_SURVEY_REPORT = ENV_TOKENS.get('ANONYMOUS_SURVEY_REPORT', False)
SURVEY_REPORT_CHECK_THRESHOLD = ENV_TOKENS.get('SURVEY_REPORT_CHECK_THRESHOLD', 6)
AVAILABLE_DISCUSSION_TOURS = ENV_TOKENS.get('AVAILABLE_DISCUSSION_TOURS', [])
############## NOTIFICATIONS EXPIRY ##############

View File

@@ -664,6 +664,7 @@ MFE_CONFIG_OVERRIDES = {
SURVEY_REPORT_EXTRA_DATA = {}
SURVEY_REPORT_ENDPOINT = "https://example.com/survey_report"
SURVEY_REPORT_CHECK_THRESHOLD = 6
SURVEY_REPORT_ENABLE = True
ANONYMOUS_SURVEY_REPORT = False
######################## Subscriptions API SETTINGS ########################

View File

@@ -21,6 +21,6 @@
{% endblock %}
{% block header %}{{ block.super }}
{% block messages %}{{ block.super }}
{% include "survey_report/admin_banner.html" %}
{% endblock %}
{% endblock %}

View File

@@ -1,7 +1,9 @@
<%page expression_filter="h"/>
<%namespace name='static' file='/static_content.html'/>
<%! from django.utils.translation import gettext as _%>
<%!
from django.utils.translation import gettext as _
from openedx.core.djangolib.js_utils import js_escaped_string
%>
<%
# set doc language direction
from django.utils.translation import get_language_bidi

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:

View File

@@ -65,6 +65,7 @@ You have the following settings to customize the behavior of your reports.
- ``ANONYMOUS_SURVEY_REPORT``: This is a boolean to specify if you want to use your LMS domain as ID for your report or to send the information anonymously with a UUID. By default, this setting is False.
- ``SURVEY_REPORT_ENABLE``: This is a boolean to specify if you want to enable or disable the survey report feature completely. The banner will disappear and the report generation will be disabled if set to False. By default, this setting is True.
About the Survey Report Admin Banner
-------------------------------------
@@ -74,4 +75,4 @@ This app implements a banner to make it easy for the Open edX operators to gener
.. image:: docs/_images/survey_report_banner.png
:alt: Survey Report Banner
**Note:** The banner will appear if a survey report is not sent in the months defined in the ``context_processor`` file, by default, is set to appear monthly.
**Note:** The banner will appear if a survey report is not sent in the months defined in the ``context_processor`` file, by default, is set to appear every 6 months.

View File

@@ -4,6 +4,7 @@ Django Admin page for SurveyReport.
from django.contrib import admin
from django.conf import settings
from .models import SurveyReport
from .api import send_report_to_external_api
@@ -21,7 +22,7 @@ class SurveyReportAdmin(admin.ModelAdmin):
)
list_display = (
'id', 'summary', 'created_at', 'state'
'id', 'summary', 'created_at', 'report_state'
)
actions = ['send_report']
@@ -80,4 +81,18 @@ class SurveyReportAdmin(admin.ModelAdmin):
del actions['delete_selected']
return actions
admin.site.register(SurveyReport, SurveyReportAdmin)
def report_state(self, obj):
"""
Method to define the custom State column with the new "send" state,
to avoid modifying the current models.
"""
try:
if obj.surveyreportupload_set.last().is_uploaded():
return "Sent"
except AttributeError:
return obj.state.capitalize()
report_state.short_description = 'State'
if settings.SURVEY_REPORT_ENABLE:
admin.site.register(SurveyReport, SurveyReportAdmin)

View File

@@ -45,6 +45,8 @@ def get_report_data() -> dict:
def generate_report() -> None:
""" Generate a report with relevant data."""
if not settings.SURVEY_REPORT_ENABLE:
raise Exception("Survey report generation is not enabled")
data = {}
survey_report = SurveyReport(**data)
survey_report.save()

View File

@@ -1,34 +1,64 @@
"""
This is the survey report contex_processor modules
This module provides context processors for integrating survey report functionality
into Django admin sites.
This is meant to determine the visibility of the survey report banner
across all admin pages in case a survey report has not been generated
It includes functions for determining whether to display a survey report banner and
calculating the date threshold for displaying the banner.
Functions:
- admin_extra_context(request):
Sends extra context to every admin site, determining whether to display the
survey report banner based on defined settings and conditions.
- should_show_survey_report_banner():
Determines whether to show the survey report banner based on the threshold.
- get_months_threshold(months):
Calculates the date threshold based on the specified number of months.
Dependencies:
- Django: settings, reverse, shortcuts
- datetime: datetime
- dateutil.relativedelta: relativedelta
Usage:
This module is designed to be imported into Django projects with admin functionality.
It enhances the admin interface by providing dynamic context for displaying a survey
report banner based on defined conditions and settings.
"""
from datetime import datetime
from dateutil.relativedelta import relativedelta # for months test
from .models import SurveyReport
from django.urls import reverse
from django.conf import settings
from django.urls import reverse
from datetime import datetime
from dateutil.relativedelta import relativedelta
from .models import SurveyReport
def admin_extra_context(request):
"""
This function sends extra context to every admin site
The current treshhold to show the banner is one month but this can be redefined in the future
This function sends extra context to every admin site.
The current threshold to show the banner is one month but this can be redefined in the future.
"""
months = settings.SURVEY_REPORT_CHECK_THRESHOLD
if not request.path.startswith(reverse('admin:index')):
return {'show_survey_report_banner': False, }
if not settings.SURVEY_REPORT_ENABLE or not request.path.startswith(reverse('admin:index')):
return {'show_survey_report_banner': False}
return {'show_survey_report_banner': should_show_survey_report_banner()}
def should_show_survey_report_banner():
"""
Determine whether to show the survey report banner based on the threshold.
"""
months_threshold = get_months_threshold(settings.SURVEY_REPORT_CHECK_THRESHOLD)
try:
latest_report = SurveyReport.objects.latest('created_at')
months_treshhold = datetime.today().date() - relativedelta(months=months) # Calculate date one month ago
show_survey_report_banner = latest_report.created_at.date() <= months_treshhold
return latest_report.created_at.date() <= months_threshold
except SurveyReport.DoesNotExist:
show_survey_report_banner = True
return True
return {'show_survey_report_banner': show_survey_report_banner, }
def get_months_threshold(months):
"""
Calculate the date threshold based on the specified number of months.
"""
return datetime.today().date() - relativedelta(months=months)

View File

@@ -11,64 +11,11 @@
<p>If you agree and want to send a report you can click the button below. You can always send reports and see the status of reports you have sent in the past at <a href="/admin/survey_report/surveyreport/">admin/survey_report/surveyreport/</a> .</p>
</div>
<div style="display: flex; justify-content: flex-end; padding: 0 37px 17px;">
<button id="dismissButton" type="button" style="background-color:var(--close-button-bg); color: var(--button-fg); border: none; border-radius: 4px; padding: 10px 20px; margin-right: 10px; cursor: pointer;">Dismiss</button>
<form id='survey_report_form' method="POST" action="/survey_report/generate_report" style="margin: 0; padding: 0;">
<form id='survey_report_form' method="POST" action="/survey_report/generate_report" style="margin: 0; padding: 0;">
{% csrf_token %}
<button type="submit" style="background-color: #377D4D; color: var(--button-fg); border: none; border-radius: 4px; padding: 10px 20px; cursor: pointer;">Send Report</button>
</form>
</div>
</div>
<div id="thankYouMessage" style="display: none; background-color: var(--darkened-bg); padding: 20px 40px; margin-bottom: 30px;box-shadow: rgb(0 0 0 / 18%) 0px 3px 5px;">
<div style="display: flex; align-items: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 24 24">
<g fill="#377D4D"><path d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2s10 4.477 10 10Z"></path>
<path d="M16.03 8.97a.75.75 0 0 1 0 1.06l-5 5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06l1.47 1.47l2.235-2.236L14.97 8.97a.75.75 0 0 1 1.06 0Z" fill="#FFF"></path>
</g>
</svg>
<span style="font-size: 16px; margin-left: 15px;">Thank you for your collaboration and support! Your contribution is greatly appreciated and will help us continue to improve.</span>
</div>
</div>
{% endif %}
<!-- The original content of the block -->
<script>
$(document).ready(function(){
$('#dismissButton').click(function() {
$('#originalContent').slideUp('slow', function() {
// If you want to do something after the slide-up, do it here.
// For example, you can hide the entire div:
// $(this).hide();
});
});
// When the form is submitted
$("#survey_report_form").submit(function(event){
event.preventDefault(); // Prevent the form from submitting traditionally
// Make the AJAX request
$.ajax({
url: $(this).attr("action"),
type: $(this).attr("method"),
data: $(this).serialize(),
success: function(response){
// Hide the original content block
$("#originalContent").slideUp(400, function() {
//$(this).css('display', 'none');
// Show the thank-you message block with slide down effect
$("#thankYouMessage").slideDown(400, function() {
// Wait for 3 seconds (3000 milliseconds) and then slide up the thank-you message
setTimeout(function() {
$("#thankYouMessage").slideUp(400);
}, 3000);
});
});
},
error: function(error){
// Handle any errors
console.error("Error sending report:", error);
}
});
});
});
</script>
{% endblock %}

View File

@@ -5,7 +5,7 @@
<li>
<form method="POST" action="{% url 'openedx.generate_survey_report' %}" class="inline">
{% csrf_token %}
<input type="submit" value="Generate Report" class="default" name="_generatereport">
<input type="submit" value="Generate and Send Report" class="default" name="_sendreport">
</form>
</li>
</ul>

View File

@@ -23,7 +23,7 @@ click>=8.0,<9.0
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
edx-enterprise==4.10.11
edx-enterprise==4.11.1
# Stay on LTS version, remove once this is added to common constraint
Django<5.0

View File

@@ -8,7 +8,7 @@
# via -r requirements/edx/github.in
acid-xblock==0.2.1
# via -r requirements/edx/kernel.in
aiohttp==3.9.1
aiohttp==3.9.3
# via
# geoip2
# openai
@@ -74,13 +74,13 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/kernel.in
boto3==1.34.28
boto3==1.34.30
# via
# -r requirements/edx/kernel.in
# django-ses
# fs-s3fs
# ora2
botocore==1.34.28
botocore==1.34.30
# via
# -r requirements/edx/kernel.in
# boto3
@@ -421,7 +421,7 @@ edx-auth-backends==4.2.0
# via
# -r requirements/edx/kernel.in
# openedx-blockstore
edx-braze-client==0.2.1
edx-braze-client==0.2.2
# via
# -r requirements/edx/bundled.in
# edx-enterprise
@@ -477,7 +477,7 @@ edx-drf-extensions==10.1.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.10.11
edx-enterprise==4.11.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
@@ -938,7 +938,7 @@ python3-openid==3.2.0 ; python_version >= "3"
# social-auth-core
python3-saml==1.16.0
# via -r requirements/edx/kernel.in
pytz==2023.3.post1
pytz==2023.4
# via
# -r requirements/edx/kernel.in
# babel
@@ -977,7 +977,7 @@ redis==5.0.1
# via
# -r requirements/edx/kernel.in
# walrus
referencing==0.32.1
referencing==0.33.0
# via
# jsonschema
# jsonschema-specifications

View File

@@ -6,7 +6,7 @@
#
chardet==5.2.0
# via diff-cover
coverage==7.4.0
coverage==7.4.1
# via -r requirements/edx/coverage.in
diff-cover==8.0.3
# via -r requirements/edx/coverage.in

View File

@@ -16,7 +16,7 @@ acid-xblock==0.2.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
aiohttp==3.9.1
aiohttp==3.9.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -145,14 +145,14 @@ boto==2.49.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
boto3==1.34.28
boto3==1.34.30
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# django-ses
# fs-s3fs
# ora2
botocore==1.34.28
botocore==1.34.30
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -281,7 +281,7 @@ coreschema==0.0.4
# -r requirements/edx/testing.txt
# coreapi
# drf-yasg
coverage[toml]==7.4.0
coverage[toml]==7.4.1
# via
# -r requirements/edx/testing.txt
# coverage
@@ -334,7 +334,7 @@ deprecated==1.2.14
# jwcrypto
diff-cover==8.0.3
# via -r requirements/edx/testing.txt
dill==0.3.7
dill==0.3.8
# via
# -r requirements/edx/testing.txt
# pylint
@@ -688,7 +688,7 @@ edx-auth-backends==4.2.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# openedx-blockstore
edx-braze-client==0.2.1
edx-braze-client==0.2.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -757,7 +757,7 @@ edx-drf-extensions==10.1.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.10.11
edx-enterprise==4.11.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
@@ -898,7 +898,7 @@ execnet==2.0.2
# pytest-xdist
factory-boy==3.3.0
# via -r requirements/edx/testing.txt
faker==22.5.1
faker==22.6.0
# via
# -r requirements/edx/testing.txt
# factory-boy
@@ -1465,11 +1465,11 @@ pycryptodomex==3.20.0
# edx-proctoring
# lti-consumer-xblock
# pyjwkest
pydantic==2.5.3
pydantic==2.6.0
# via
# -r requirements/edx/testing.txt
# fastapi
pydantic-core==2.14.6
pydantic-core==2.16.1
# via
# -r requirements/edx/testing.txt
# pydantic
@@ -1594,7 +1594,7 @@ pysrt==1.1.2
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edxval
pytest==7.4.4
pytest==8.0.0
# via
# -r requirements/edx/testing.txt
# pylint-pytest
@@ -1667,7 +1667,7 @@ python3-saml==1.16.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
pytz==2023.3.post1
pytz==2023.4
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1718,7 +1718,7 @@ redis==5.0.1
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# walrus
referencing==0.32.1
referencing==0.33.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -2039,7 +2039,7 @@ tqdm==4.66.1
# -r requirements/edx/testing.txt
# nltk
# openai
types-pytz==2023.3.1.1
types-pytz==2023.4.0.20240130
# via django-stubs
types-pyyaml==6.0.12.12
# via
@@ -2111,7 +2111,7 @@ user-util==1.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
uvicorn==0.27.0
uvicorn==0.27.0.post1
# via
# -r requirements/edx/testing.txt
# pact-python

View File

@@ -10,7 +10,7 @@ accessible-pygments==0.0.4
# via pydata-sphinx-theme
acid-xblock==0.2.1
# via -r requirements/edx/base.txt
aiohttp==3.9.1
aiohttp==3.9.3
# via
# -r requirements/edx/base.txt
# geoip2
@@ -103,13 +103,13 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
boto3==1.34.28
boto3==1.34.30
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
botocore==1.34.28
botocore==1.34.30
# via
# -r requirements/edx/base.txt
# boto3
@@ -499,7 +499,7 @@ edx-auth-backends==4.2.0
# via
# -r requirements/edx/base.txt
# openedx-blockstore
edx-braze-client==0.2.1
edx-braze-client==0.2.2
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -555,7 +555,7 @@ edx-drf-extensions==10.1.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.10.11
edx-enterprise==4.11.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -1121,7 +1121,7 @@ python3-openid==3.2.0 ; python_version >= "3"
# social-auth-core
python3-saml==1.16.0
# via -r requirements/edx/base.txt
pytz==2023.3.post1
pytz==2023.4
# via
# -r requirements/edx/base.txt
# babel
@@ -1161,7 +1161,7 @@ redis==5.0.1
# via
# -r requirements/edx/base.txt
# walrus
referencing==0.32.1
referencing==0.33.0
# via
# -r requirements/edx/base.txt
# jsonschema

View File

@@ -60,7 +60,7 @@ pkgutil-resolve-name==1.3.10
# via jsonschema
pygments==2.17.2
# via rich
referencing==0.32.1
referencing==0.33.0
# via
# jsonschema
# jsonschema-specifications

View File

@@ -8,7 +8,7 @@
# via -r requirements/edx/base.txt
acid-xblock==0.2.1
# via -r requirements/edx/base.txt
aiohttp==3.9.1
aiohttp==3.9.3
# via
# -r requirements/edx/base.txt
# geoip2
@@ -105,13 +105,13 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
boto3==1.34.28
boto3==1.34.30
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
botocore==1.34.28
botocore==1.34.30
# via
# -r requirements/edx/base.txt
# boto3
@@ -209,7 +209,7 @@ coreschema==0.0.4
# -r requirements/edx/base.txt
# coreapi
# drf-yasg
coverage[toml]==7.4.0
coverage[toml]==7.4.1
# via
# -r requirements/edx/coverage.txt
# pytest-cov
@@ -251,7 +251,7 @@ deprecated==1.2.14
# jwcrypto
diff-cover==8.0.3
# via -r requirements/edx/coverage.txt
dill==0.3.7
dill==0.3.8
# via pylint
distlib==0.3.8
# via virtualenv
@@ -525,7 +525,7 @@ edx-auth-backends==4.2.0
# via
# -r requirements/edx/base.txt
# openedx-blockstore
edx-braze-client==0.2.1
edx-braze-client==0.2.2
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -581,7 +581,7 @@ edx-drf-extensions==10.1.0
# edx-when
# edxval
# openedx-learning
edx-enterprise==4.10.11
edx-enterprise==4.11.1
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -687,7 +687,7 @@ execnet==2.0.2
# via pytest-xdist
factory-boy==3.3.0
# via -r requirements/edx/testing.in
faker==22.5.1
faker==22.6.0
# via factory-boy
fastapi==0.109.0
# via pact-python
@@ -1097,9 +1097,9 @@ pycryptodomex==3.20.0
# edx-proctoring
# lti-consumer-xblock
# pyjwkest
pydantic==2.5.3
pydantic==2.6.0
# via fastapi
pydantic-core==2.14.6
pydantic-core==2.16.1
# via pydantic
pygments==2.17.2
# via
@@ -1187,7 +1187,7 @@ pysrt==1.1.2
# via
# -r requirements/edx/base.txt
# edxval
pytest==7.4.4
pytest==8.0.0
# via
# -r requirements/edx/testing.in
# pylint-pytest
@@ -1249,7 +1249,7 @@ python3-openid==3.2.0 ; python_version >= "3"
# social-auth-core
python3-saml==1.16.0
# via -r requirements/edx/base.txt
pytz==2023.3.post1
pytz==2023.4
# via
# -r requirements/edx/base.txt
# babel
@@ -1288,7 +1288,7 @@ redis==5.0.1
# via
# -r requirements/edx/base.txt
# walrus
referencing==0.32.1
referencing==0.33.0
# via
# -r requirements/edx/base.txt
# jsonschema
@@ -1547,7 +1547,7 @@ urllib3==1.26.18
# snowflake-connector-python
user-util==1.0.0
# via -r requirements/edx/base.txt
uvicorn==0.27.0
uvicorn==0.27.0.post1
# via pact-python
vine==5.1.0
# via