Files
edx-platform/cms/djangoapps/contentstore/signals/handlers.py
Jillian c02e567201 Make copied tags editable again after breaking the upstream link to library content [FC-0076] (#36228)
When deleting an upstream library block, ensure that any tags that may have been copied to downstream blocks are made editable again. This is achieved by un-setting the `is_copied` flag on the downstream tags.
2025-02-13 11:53:05 -05:00

314 lines
12 KiB
Python

""" receivers of course_published and library_updated events in order to trigger indexing task """
import logging
from datetime import datetime, timezone
from functools import wraps
from typing import Optional
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.dispatch import receiver
from edx_toggles.toggles import SettingToggle
from opaque_keys.edx.keys import CourseKey
from openedx_events.content_authoring.data import (
CourseCatalogData,
CourseData,
CourseScheduleData,
LibraryBlockData,
XBlockData,
)
from openedx_events.content_authoring.signals import (
COURSE_CATALOG_INFO_CHANGED,
COURSE_IMPORT_COMPLETED,
LIBRARY_BLOCK_DELETED,
XBLOCK_CREATED,
XBLOCK_DELETED,
XBLOCK_UPDATED,
)
from pytz import UTC
from cms.djangoapps.contentstore.courseware_index import (
CourseAboutSearchIndexer,
CoursewareSearchIndexer,
LibrarySearchIndexer,
)
from common.djangoapps.track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type
from common.djangoapps.util.block_utils import yield_dynamic_block_descendants
from lms.djangoapps.grades.api import task_compute_all_grades_for_course
from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines
from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
from openedx.core.lib.gating import api as gating_api
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import SignalHandler, modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from ..models import PublishableEntityLink
from ..tasks import (
create_or_update_upstream_links,
handle_create_or_update_xblock_upstream_link,
handle_unlink_upstream_block,
)
from .signals import GRADING_POLICY_CHANGED
log = logging.getLogger(__name__)
GRADING_POLICY_COUNTDOWN_SECONDS = 3600
def locked(expiry_seconds, key): # lint-amnesty, pylint: disable=missing-function-docstring
def task_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
cache_key = f'{func.__name__}-{kwargs[key]}'
if cache.add(cache_key, "true", expiry_seconds):
log.info('Locking task in cache with key: %s for %s seconds', cache_key, expiry_seconds)
return func(*args, **kwargs)
else:
log.info('Task with key %s already exists in cache', cache_key)
return wrapper
return task_decorator
# .. toggle_name: SEND_CATALOG_INFO_SIGNAL
# .. toggle_implementation: SettingToggle
# .. toggle_default: False
# .. toggle_description: When True, sends to catalog-info-changed signal when course_published occurs.
# This is a temporary toggle to allow us to test the event bus integration; it should be removed and
# always-on once the integration is well-tested and the error cases are handled. (This is separate
# from whether the event bus itself is configured; if this toggle is on but the event bus is not
# configured, we should expect a warning at most.)
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2022-08-22
# .. toggle_target_removal_date: 2022-10-30
# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/30682
SEND_CATALOG_INFO_SIGNAL = SettingToggle('SEND_CATALOG_INFO_SIGNAL', default=False, module_name=__name__)
def _create_catalog_data_for_signal(course_key: CourseKey) -> (Optional[datetime], Optional[CourseCatalogData]):
"""
Creates data for catalog-info-changed signal when course is published.
Arguments:
course_key: Key of the course to announce catalog info changes for
Returns:
(datetime, CourseCatalogData): Tuple including the timestamp of the
event, and data for signal, or (None, None) if not appropriate
to send on this signal.
"""
# Only operate on real courses, not libraries.
if not course_key.is_course:
return None, None
store = modulestore()
with store.branch_setting(ModuleStoreEnum.Branch.published_only, course_key):
course = store.get_course(course_key)
timestamp = course.subtree_edited_on.replace(tzinfo=timezone.utc)
return timestamp, CourseCatalogData(
course_key=course_key.for_branch(None), # Shouldn't be necessary, but just in case...
name=course.display_name,
schedule_data=CourseScheduleData(
start=course.start,
pacing='self' if course.self_paced else 'instructor',
end=course.end,
enrollment_start=course.enrollment_start,
enrollment_end=course.enrollment_end,
),
hidden=course.catalog_visibility in ['about', 'none'] or course_key.deprecated,
invitation_only=course.invitation_only,
)
def emit_catalog_info_changed_signal(course_key: CourseKey):
"""
Given the key of a recently published course, send course data to catalog-info-changed signal.
"""
if SEND_CATALOG_INFO_SIGNAL.is_enabled():
timestamp, catalog_info = _create_catalog_data_for_signal(course_key)
if catalog_info is not None:
COURSE_CATALOG_INFO_CHANGED.send_event(time=timestamp, catalog_info=catalog_info)
@receiver(SignalHandler.course_published)
def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
"""
Receives publishing signal and performs publishing related workflows, such as
registering proctored exams, building up credit requirements, and performing
search indexing
"""
# import here, because signal is registered at startup, but items in tasks are not yet able to be loaded
from cms.djangoapps.contentstore.tasks import (
update_outline_from_modulestore_task,
update_search_index,
update_special_exams_and_publish
)
# DEVELOPER README: probably all tasks here should use transaction.on_commit
# to avoid stale data, but the tasks are owned by many teams and are often
# working well enough. Several instead use a waiting strategy.
# If you are in here trying to figure out why your task is not working correctly,
# consider whether it is getting stale data and if so choose to wait for the transaction
# like exams or put your task to sleep for a while like discussions.
# You will not be able to replicate these errors in an environment where celery runs
# in process because it will be inside the transaction. Use the settings from
# devstack_with_worker.py, and consider adding a time.sleep into send_bulk_published_signal
# if you really want to make sure that the task happens before the data is ready.
# register special exams asynchronously after the data is ready
course_key_str = str(course_key)
transaction.on_commit(lambda: update_special_exams_and_publish.delay(course_key_str))
if key_supports_outlines(course_key):
# Push the course outline to learning_sequences asynchronously.
update_outline_from_modulestore_task.delay(course_key_str)
# Kick off a courseware indexing action after the data is ready
if CoursewareSearchIndexer.indexing_is_enabled() and CourseAboutSearchIndexer.indexing_is_enabled():
transaction.on_commit(lambda: update_search_index.delay(course_key_str, datetime.now(UTC).isoformat()))
update_discussions_settings_from_course_task.apply_async(
args=[course_key_str],
countdown=settings.DISCUSSION_SETTINGS['COURSE_PUBLISH_TASK_DELAY'],
)
# Send to a signal for catalog info changes as well, but only once we know the transaction is committed.
transaction.on_commit(lambda: emit_catalog_info_changed_signal(course_key))
@receiver(SignalHandler.course_deleted)
def listen_for_course_delete(sender, course_key, **kwargs): # pylint: disable=unused-argument
"""
Catches the signal that a course has been deleted
and removes its entry from the Course About Search index.
"""
CourseAboutSearchIndexer.remove_deleted_items(course_key)
@receiver(SignalHandler.library_updated)
def listen_for_library_update(sender, library_key, **kwargs): # pylint: disable=unused-argument
"""
Receives signal and kicks off celery task to update search index
"""
if LibrarySearchIndexer.indexing_is_enabled():
# import here, because signal is registered at startup, but items in tasks are not yet able to be loaded
from cms.djangoapps.contentstore.tasks import update_library_index
update_library_index.delay(str(library_key), datetime.now(UTC).isoformat())
@receiver(SignalHandler.item_deleted)
def handle_item_deleted(**kwargs):
"""
Receives the item_deleted signal sent by Studio when an XBlock is removed from
the course structure and removes any gating milestone data associated with it or
its descendants.
Arguments:
kwargs (dict): Contains the content usage key of the item deleted
Returns:
None
"""
usage_key = kwargs.get('usage_key')
if usage_key:
# Strip branch info
usage_key = usage_key.for_branch(None)
course_key = usage_key.course_key
try:
deleted_block = modulestore().get_item(usage_key)
except ItemNotFoundError:
return
id_list = {deleted_block.location}
for block in yield_dynamic_block_descendants(deleted_block, kwargs.get('user_id')):
# Remove prerequisite milestone data
gating_api.remove_prerequisite(block.location)
# Remove any 'requires' course content milestone relationships
gating_api.set_required_content(course_key, block.location, None, None, None)
id_list.add(block.location)
PublishableEntityLink.objects.filter(downstream_usage_key__in=id_list).delete()
@receiver(GRADING_POLICY_CHANGED)
@locked(expiry_seconds=GRADING_POLICY_COUNTDOWN_SECONDS, key='course_key')
def handle_grading_policy_changed(sender, **kwargs):
# pylint: disable=unused-argument
"""
Receives signal and kicks off celery task to recalculate grades
"""
kwargs = {
'course_key': str(kwargs.get('course_key')),
'grading_policy_hash': str(kwargs.get('grading_policy_hash')),
'event_transaction_id': str(get_event_transaction_id()),
'event_transaction_type': str(get_event_transaction_type()),
}
result = task_compute_all_grades_for_course.apply_async(kwargs=kwargs, countdown=GRADING_POLICY_COUNTDOWN_SECONDS)
log.info("Grades: Created {task_name}[{task_id}] with arguments {kwargs}".format(
task_name=task_compute_all_grades_for_course.name,
task_id=result.task_id,
kwargs=kwargs,
))
@receiver(XBLOCK_CREATED)
@receiver(XBLOCK_UPDATED)
def create_or_update_upstream_downstream_link_handler(**kwargs):
"""
Automatically create or update upstream->downstream link in database.
"""
xblock_info = kwargs.get("xblock_info", None)
if not xblock_info or not isinstance(xblock_info, XBlockData):
log.error("Received null or incorrect data for event")
return
handle_create_or_update_xblock_upstream_link.delay(str(xblock_info.usage_key))
@receiver(XBLOCK_DELETED)
def delete_upstream_downstream_link_handler(**kwargs):
"""
Delete upstream->downstream link from database on xblock delete.
"""
xblock_info = kwargs.get("xblock_info", None)
if not xblock_info or not isinstance(xblock_info, XBlockData):
log.error("Received null or incorrect data for event")
return
PublishableEntityLink.objects.filter(
downstream_usage_key=xblock_info.usage_key
).delete()
@receiver(COURSE_IMPORT_COMPLETED)
def handle_new_course_import(**kwargs):
"""
Automatically create upstream->downstream links for course in database on new import.
"""
course_data = kwargs.get("course", None)
if not course_data or not isinstance(course_data, CourseData):
log.error("Received null or incorrect data for event")
return
create_or_update_upstream_links.delay(
str(course_data.course_key),
force=True,
replace=True
)
@receiver(LIBRARY_BLOCK_DELETED)
def unlink_upstream_block_handler(**kwargs):
"""
Handle unlinking the upstream (library) block from any downstream (course) blocks.
"""
library_block = kwargs.get("library_block", None)
if not library_block or not isinstance(library_block, LibraryBlockData):
log.error("Received null or incorrect data for event")
return
handle_unlink_upstream_block.delay(str(library_block.usage_key))