diff --git a/requirements/base.in b/requirements/base.in index 508635e10..626a551be 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -10,6 +10,8 @@ Django # Web application framework djangorestframework<4.0 # REST API edx-drf-extensions # Extensions to the Django REST Framework used by Open edX +openedx-events # For sending events to the openedx event bus + rules<4.0 # Django extension for rules-based authorization checks tomlkit # Parses and writes TOML configuration files diff --git a/requirements/base.txt b/requirements/base.txt index ec4cc8193..d982d21ad 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -9,7 +9,9 @@ amqp==5.3.1 asgiref==3.11.1 # via django attrs==26.1.0 - # via -r requirements/base.in + # via + # -r requirements/base.in + # openedx-events billiard==4.2.4 # via celery celery==5.6.3 @@ -20,9 +22,9 @@ cffi==2.0.0 # via # cryptography # pynacl -charset-normalizer==3.4.7 +charset-normalizer==3.4.6 # via requests -click==8.3.2 +click==8.3.1 # via # celery # click-didyoumean @@ -50,6 +52,7 @@ django==5.2.13 # edx-django-utils # edx-drf-extensions # edx-organizations + # openedx-events django-crum==0.7.9 # via edx-django-utils django-model-utils==5.0.0 @@ -70,25 +73,35 @@ dnspython==2.8.0 # via pymongo drf-jwt==1.19.2 # via edx-drf-extensions +edx-ccx-keys==2.0.2 + # via openedx-events edx-django-utils==8.0.1 - # via edx-drf-extensions + # via + # edx-drf-extensions + # openedx-events edx-drf-extensions==10.6.0 # via # -r requirements/base.in # edx-organizations -edx-opaque-keys==4.0.0 +edx-opaque-keys[django]==4.0.0 # via + # edx-ccx-keys # edx-drf-extensions # edx-organizations + # openedx-events edx-organizations==8.0.0 # via -r requirements/base.in +fastavro==1.12.1 + # via openedx-events idna==3.11 # via requests kombu==5.6.2 # via celery +openedx-events==11.1.0 + # via -r requirements/base.in packaging==26.0 # via kombu -pillow==12.2.0 +pillow==12.1.1 # via edx-organizations prompt-toolkit==3.0.52 # via click-repl @@ -106,14 +119,16 @@ pynacl==1.6.2 # via edx-django-utils python-dateutil==2.9.0.post0 # via celery -requests==2.33.1 +requests==2.33.0 # via edx-drf-extensions rules==3.5 # via -r requirements/base.in semantic-version==2.10.0 # via edx-drf-extensions six==1.17.0 - # via python-dateutil + # via + # edx-ccx-keys + # python-dateutil sqlparse==0.5.5 # via django stevedore==5.7.0 @@ -124,7 +139,7 @@ tomlkit==0.14.0 # via -r requirements/base.in typing-extensions==4.15.0 # via edx-opaque-keys -tzdata==2026.1 +tzdata==2025.3 # via kombu tzlocal==5.3.1 # via celery diff --git a/requirements/dev.txt b/requirements/dev.txt index 75477da31..e7816253b 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -18,7 +18,9 @@ astroid==4.0.4 # pylint # pylint-celery attrs==26.1.0 - # via -r requirements/quality.txt + # via + # -r requirements/quality.txt + # openedx-events billiard==4.2.4 # via # -r requirements/quality.txt @@ -44,11 +46,11 @@ cffi==2.0.0 # pynacl chardet==7.4.1 # via diff-cover -charset-normalizer==3.4.7 +charset-normalizer==3.4.6 # via # -r requirements/quality.txt # requests -click==8.3.2 +click==8.3.1 # via # -r requirements/pip-tools.txt # -r requirements/quality.txt @@ -94,7 +96,6 @@ cryptography==46.0.7 # via # -r requirements/quality.txt # pyjwt - # secretstorage ddt==1.7.2 # via -r requirements/quality.txt diff-cover==10.2.0 @@ -124,11 +125,12 @@ django==5.2.13 # edx-drf-extensions # edx-i18n-tools # edx-organizations + # openedx-events django-crum==0.7.9 # via # -r requirements/quality.txt # edx-django-utils -django-debug-toolbar==6.3.0 +django-debug-toolbar==6.2.0 # via # -r requirements/dev.in # -r requirements/quality.txt @@ -140,11 +142,11 @@ django-simple-history==3.11.0 # via # -r requirements/quality.txt # edx-organizations -django-stubs==6.0.2 +django-stubs==6.0.1 # via # -r requirements/quality.txt # djangorestframework-stubs -django-stubs-ext==6.0.2 +django-stubs-ext==6.0.1 # via # -r requirements/quality.txt # django-stubs @@ -159,7 +161,7 @@ djangorestframework==3.17.1 # drf-jwt # edx-drf-extensions # edx-organizations -djangorestframework-stubs==3.16.9 +djangorestframework-stubs==3.16.8 # via -r requirements/quality.txt dnspython==2.8.0 # via @@ -173,10 +175,15 @@ drf-jwt==1.19.2 # via # -r requirements/quality.txt # edx-drf-extensions +edx-ccx-keys==2.0.2 + # via + # -r requirements/quality.txt + # openedx-events edx-django-utils==8.0.1 # via # -r requirements/quality.txt # edx-drf-extensions + # openedx-events edx-drf-extensions==10.6.0 # via # -r requirements/quality.txt @@ -185,13 +192,19 @@ edx-i18n-tools==2.0.0 # via -r requirements/dev.in edx-lint==6.0.0 # via -r requirements/quality.txt -edx-opaque-keys==4.0.0 +edx-opaque-keys[django]==4.0.0 # via # -r requirements/quality.txt + # edx-ccx-keys # edx-drf-extensions # edx-organizations + # openedx-events edx-organizations==8.0.0 # via -r requirements/quality.txt +fastavro==1.12.1 + # via + # -r requirements/quality.txt + # openedx-events filelock==3.25.2 # via # -r requirements/ci.txt @@ -234,11 +247,6 @@ jaraco-functools==4.4.0 # via # -r requirements/quality.txt # keyring -jeepney==0.9.0 - # via - # -r requirements/quality.txt - # keyring - # secretstorage jinja2==3.1.6 # via # -r requirements/quality.txt @@ -285,7 +293,7 @@ more-itertools==11.0.2 # -r requirements/quality.txt # jaraco-classes # jaraco-functools -mypy==1.20.0 +mypy==1.19.1 # via -r requirements/quality.txt mypy-extensions==1.1.0 # via @@ -297,6 +305,8 @@ nh3==0.3.4 # via # -r requirements/quality.txt # readme-renderer +openedx-events==11.1.0 + # via -r requirements/quality.txt packaging==26.0 # via # -r requirements/ci.txt @@ -315,7 +325,7 @@ pathspec==1.0.4 # via # -r requirements/quality.txt # mypy -pillow==12.2.0 +pillow==12.1.1 # via # -r requirements/quality.txt # edx-organizations @@ -436,7 +446,7 @@ readme-renderer==44.0 # via # -r requirements/quality.txt # twine -requests==2.33.1 +requests==2.33.0 # via # -r requirements/quality.txt # edx-drf-extensions @@ -457,10 +467,6 @@ rich==15.0.0 # twine rules==3.5 # via -r requirements/quality.txt -secretstorage==3.5.0 - # via - # -r requirements/quality.txt - # keyring semantic-version==2.10.0 # via # -r requirements/quality.txt @@ -468,6 +474,7 @@ semantic-version==2.10.0 six==1.17.0 # via # -r requirements/quality.txt + # edx-ccx-keys # edx-lint # python-dateutil snowballstemmer==3.0.1 @@ -516,7 +523,7 @@ typing-extensions==4.15.0 # grimp # import-linter # mypy -tzdata==2026.1 +tzdata==2025.3 # via # -r requirements/quality.txt # kombu diff --git a/requirements/doc.txt b/requirements/doc.txt index 4d815f689..ee1f6b79f 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -17,7 +17,9 @@ asgiref==3.11.1 # -r requirements/test.txt # django attrs==26.1.0 - # via -r requirements/test.txt + # via + # -r requirements/test.txt + # openedx-events babel==2.18.0 # via # pydata-sphinx-theme @@ -39,11 +41,11 @@ cffi==2.0.0 # -r requirements/test.txt # cryptography # pynacl -charset-normalizer==3.4.7 +charset-normalizer==3.4.6 # via # -r requirements/test.txt # requests -click==8.3.2 +click==8.3.1 # via # -r requirements/test.txt # celery @@ -93,12 +95,13 @@ django==5.2.13 # edx-django-utils # edx-drf-extensions # edx-organizations + # openedx-events # sphinxcontrib-django django-crum==0.7.9 # via # -r requirements/test.txt # edx-django-utils -django-debug-toolbar==6.3.0 +django-debug-toolbar==6.2.0 # via -r requirements/test.txt django-model-utils==5.0.0 # via @@ -108,11 +111,11 @@ django-simple-history==3.11.0 # via # -r requirements/test.txt # edx-organizations -django-stubs==6.0.2 +django-stubs==6.0.1 # via # -r requirements/test.txt # djangorestframework-stubs -django-stubs-ext==6.0.2 +django-stubs-ext==6.0.1 # via # -r requirements/test.txt # django-stubs @@ -127,7 +130,7 @@ djangorestframework==3.17.1 # drf-jwt # edx-drf-extensions # edx-organizations -djangorestframework-stubs==3.16.9 +djangorestframework-stubs==3.16.8 # via -r requirements/test.txt dnspython==2.8.0 # via @@ -146,21 +149,32 @@ drf-jwt==1.19.2 # via # -r requirements/test.txt # edx-drf-extensions +edx-ccx-keys==2.0.2 + # via + # -r requirements/test.txt + # openedx-events edx-django-utils==8.0.1 # via # -r requirements/test.txt # edx-drf-extensions + # openedx-events edx-drf-extensions==10.6.0 # via # -r requirements/test.txt # edx-organizations -edx-opaque-keys==4.0.0 +edx-opaque-keys[django]==4.0.0 # via # -r requirements/test.txt + # edx-ccx-keys # edx-drf-extensions # edx-organizations + # openedx-events edx-organizations==8.0.0 # via -r requirements/test.txt +fastavro==1.12.1 + # via + # -r requirements/test.txt + # openedx-events freezegun==1.5.5 # via -r requirements/test.txt grimp==3.14 @@ -206,7 +220,7 @@ mdurl==0.1.2 # markdown-it-py mock==5.2.0 # via -r requirements/test.txt -mypy==1.20.0 +mypy==1.19.1 # via -r requirements/test.txt mypy-extensions==1.1.0 # via @@ -216,6 +230,8 @@ mysqlclient==2.2.8 # via -r requirements/test.txt nh3==0.3.4 # via readme-renderer +openedx-events==11.1.0 + # via -r requirements/test.txt packaging==26.0 # via # -r requirements/test.txt @@ -226,7 +242,7 @@ pathspec==1.0.4 # via # -r requirements/test.txt # mypy -pillow==12.2.0 +pillow==12.1.1 # via # -r requirements/test.txt # edx-organizations @@ -298,7 +314,7 @@ pyyaml==6.0.3 # code-annotations readme-renderer==44.0 # via -r requirements/doc.in -requests==2.33.1 +requests==2.33.0 # via # -r requirements/test.txt # edx-drf-extensions @@ -320,6 +336,7 @@ semantic-version==2.10.0 six==1.17.0 # via # -r requirements/test.txt + # edx-ccx-keys # python-dateutil snowballstemmer==3.0.1 # via sphinx @@ -382,7 +399,7 @@ typing-extensions==4.15.0 # import-linter # mypy # pydata-sphinx-theme -tzdata==2026.1 +tzdata==2025.3 # via # -r requirements/test.txt # kombu diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index e7d1c474f..1375ece99 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -6,7 +6,7 @@ # build==1.4.3 # via pip-tools -click==8.3.2 +click==8.3.1 # via pip-tools packaging==26.0 # via diff --git a/requirements/quality.in b/requirements/quality.in index d4773865e..d3021049e 100644 --- a/requirements/quality.in +++ b/requirements/quality.in @@ -5,6 +5,7 @@ edx-lint # edX pylint rules and plugins isort # to standardize order of imports +openedx-events # For testing event emission pycodestyle # PEP 8 compliance validation pydocstyle # PEP 257 compliance validation twine # Utility for publishing Python packages on PyPI. diff --git a/requirements/quality.txt b/requirements/quality.txt index b7b370d1b..f146cbd99 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -17,7 +17,9 @@ astroid==4.0.4 # pylint # pylint-celery attrs==26.1.0 - # via -r requirements/test.txt + # via + # -r requirements/test.txt + # openedx-events billiard==4.2.4 # via # -r requirements/test.txt @@ -33,11 +35,11 @@ cffi==2.0.0 # -r requirements/test.txt # cryptography # pynacl -charset-normalizer==3.4.7 +charset-normalizer==3.4.6 # via # -r requirements/test.txt # requests -click==8.3.2 +click==8.3.1 # via # -r requirements/test.txt # celery @@ -75,7 +77,6 @@ cryptography==46.0.7 # via # -r requirements/test.txt # pyjwt - # secretstorage ddt==1.7.2 # via -r requirements/test.txt dill==0.4.1 @@ -96,11 +97,12 @@ django==5.2.13 # edx-django-utils # edx-drf-extensions # edx-organizations + # openedx-events django-crum==0.7.9 # via # -r requirements/test.txt # edx-django-utils -django-debug-toolbar==6.3.0 +django-debug-toolbar==6.2.0 # via -r requirements/test.txt django-model-utils==5.0.0 # via @@ -110,11 +112,11 @@ django-simple-history==3.11.0 # via # -r requirements/test.txt # edx-organizations -django-stubs==6.0.2 +django-stubs==6.0.1 # via # -r requirements/test.txt # djangorestframework-stubs -django-stubs-ext==6.0.2 +django-stubs-ext==6.0.1 # via # -r requirements/test.txt # django-stubs @@ -129,7 +131,7 @@ djangorestframework==3.17.1 # drf-jwt # edx-drf-extensions # edx-organizations -djangorestframework-stubs==3.16.9 +djangorestframework-stubs==3.16.8 # via -r requirements/test.txt dnspython==2.8.0 # via @@ -141,23 +143,34 @@ drf-jwt==1.19.2 # via # -r requirements/test.txt # edx-drf-extensions +edx-ccx-keys==2.0.2 + # via + # -r requirements/test.txt + # openedx-events edx-django-utils==8.0.1 # via # -r requirements/test.txt # edx-drf-extensions + # openedx-events edx-drf-extensions==10.6.0 # via # -r requirements/test.txt # edx-organizations edx-lint==6.0.0 # via -r requirements/quality.in -edx-opaque-keys==4.0.0 +edx-opaque-keys[django]==4.0.0 # via # -r requirements/test.txt + # edx-ccx-keys # edx-drf-extensions # edx-organizations + # openedx-events edx-organizations==8.0.0 # via -r requirements/test.txt +fastavro==1.12.1 + # via + # -r requirements/test.txt + # openedx-events freezegun==1.5.5 # via -r requirements/test.txt grimp==3.14 @@ -186,10 +199,6 @@ jaraco-context==6.1.2 # via keyring jaraco-functools==4.4.0 # via keyring -jeepney==0.9.0 - # via - # keyring - # secretstorage jinja2==3.1.6 # via # -r requirements/test.txt @@ -224,7 +233,7 @@ more-itertools==11.0.2 # via # jaraco-classes # jaraco-functools -mypy==1.20.0 +mypy==1.19.1 # via -r requirements/test.txt mypy-extensions==1.1.0 # via @@ -234,6 +243,10 @@ mysqlclient==2.2.8 # via -r requirements/test.txt nh3==0.3.4 # via readme-renderer +openedx-events==11.1.0 + # via + # -r requirements/quality.in + # -r requirements/test.txt packaging==26.0 # via # -r requirements/test.txt @@ -244,7 +257,7 @@ pathspec==1.0.4 # via # -r requirements/test.txt # mypy -pillow==12.2.0 +pillow==12.1.1 # via # -r requirements/test.txt # edx-organizations @@ -328,7 +341,7 @@ pyyaml==6.0.3 # code-annotations readme-renderer==44.0 # via twine -requests==2.33.1 +requests==2.33.0 # via # -r requirements/test.txt # edx-drf-extensions @@ -345,8 +358,6 @@ rich==15.0.0 # twine rules==3.5 # via -r requirements/test.txt -secretstorage==3.5.0 - # via keyring semantic-version==2.10.0 # via # -r requirements/test.txt @@ -354,6 +365,7 @@ semantic-version==2.10.0 six==1.17.0 # via # -r requirements/test.txt + # edx-ccx-keys # edx-lint # python-dateutil snowballstemmer==3.0.1 @@ -394,7 +406,7 @@ typing-extensions==4.15.0 # grimp # import-linter # mypy -tzdata==2026.1 +tzdata==2025.3 # via # -r requirements/test.txt # kombu diff --git a/requirements/test.txt b/requirements/test.txt index c22f57f6f..64381ada6 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -13,7 +13,9 @@ asgiref==3.11.1 # -r requirements/base.txt # django attrs==26.1.0 - # via -r requirements/base.txt + # via + # -r requirements/base.txt + # openedx-events billiard==4.2.4 # via # -r requirements/base.txt @@ -29,11 +31,11 @@ cffi==2.0.0 # -r requirements/base.txt # cryptography # pynacl -charset-normalizer==3.4.7 +charset-normalizer==3.4.6 # via # -r requirements/base.txt # requests -click==8.3.2 +click==8.3.1 # via # -r requirements/base.txt # celery @@ -82,11 +84,12 @@ ddt==1.7.2 # edx-django-utils # edx-drf-extensions # edx-organizations + # openedx-events django-crum==0.7.9 # via # -r requirements/base.txt # edx-django-utils -django-debug-toolbar==6.3.0 +django-debug-toolbar==6.2.0 # via -r requirements/test.in django-model-utils==5.0.0 # via @@ -96,11 +99,11 @@ django-simple-history==3.11.0 # via # -r requirements/base.txt # edx-organizations -django-stubs==6.0.2 +django-stubs==6.0.1 # via # -r requirements/test.in # djangorestframework-stubs -django-stubs-ext==6.0.2 +django-stubs-ext==6.0.1 # via django-stubs django-waffle==5.0.0 # via @@ -113,7 +116,7 @@ djangorestframework==3.17.1 # drf-jwt # edx-drf-extensions # edx-organizations -djangorestframework-stubs==3.16.9 +djangorestframework-stubs==3.16.8 # via -r requirements/test.in dnspython==2.8.0 # via @@ -123,21 +126,32 @@ drf-jwt==1.19.2 # via # -r requirements/base.txt # edx-drf-extensions +edx-ccx-keys==2.0.2 + # via + # -r requirements/base.txt + # openedx-events edx-django-utils==8.0.1 # via # -r requirements/base.txt # edx-drf-extensions + # openedx-events edx-drf-extensions==10.6.0 # via # -r requirements/base.txt # edx-organizations -edx-opaque-keys==4.0.0 +edx-opaque-keys[django]==4.0.0 # via # -r requirements/base.txt + # edx-ccx-keys # edx-drf-extensions # edx-organizations + # openedx-events edx-organizations==8.0.0 # via -r requirements/base.txt +fastavro==1.12.1 + # via + # -r requirements/base.txt + # openedx-events freezegun==1.5.5 # via -r requirements/test.in grimp==3.14 @@ -166,12 +180,14 @@ mdurl==0.1.2 # via markdown-it-py mock==5.2.0 # via -r requirements/test.in -mypy==1.20.0 +mypy==1.19.1 # via -r requirements/test.in mypy-extensions==1.1.0 # via mypy mysqlclient==2.2.8 # via -r requirements/test.in +openedx-events==11.1.0 + # via -r requirements/base.txt packaging==26.0 # via # -r requirements/base.txt @@ -179,7 +195,7 @@ packaging==26.0 # pytest pathspec==1.0.4 # via mypy -pillow==12.2.0 +pillow==12.1.1 # via # -r requirements/base.txt # edx-organizations @@ -234,7 +250,7 @@ python-slugify==8.0.4 # via code-annotations pyyaml==6.0.3 # via code-annotations -requests==2.33.1 +requests==2.33.0 # via # -r requirements/base.txt # edx-drf-extensions @@ -249,6 +265,7 @@ semantic-version==2.10.0 six==1.17.0 # via # -r requirements/base.txt + # edx-ccx-keys # python-dateutil sqlparse==0.5.5 # via @@ -279,7 +296,7 @@ typing-extensions==4.15.0 # grimp # import-linter # mypy -tzdata==2026.1 +tzdata==2025.3 # via # -r requirements/base.txt # kombu diff --git a/src/openedx_core/__init__.py b/src/openedx_core/__init__.py index d5c904422..0c2d2a835 100644 --- a/src/openedx_core/__init__.py +++ b/src/openedx_core/__init__.py @@ -6,4 +6,4 @@ """ # The version for the entire repository -__version__ = "0.38.2" +__version__ = "0.38.3" diff --git a/src/openedx_tagging/apps.py b/src/openedx_tagging/apps.py index 5e1192c80..87410cab5 100644 --- a/src/openedx_tagging/apps.py +++ b/src/openedx_tagging/apps.py @@ -17,3 +17,7 @@ class TaggingConfig(AppConfig): # Historical note: "oel" comes from "Open edX Learning", the original # name of this apps's repository. label = "oel_tagging" + + def ready(self): + # Import signal handlers so Django registers all @receiver callbacks. + from . import signal_handlers # pylint: disable=unused-import,import-outside-toplevel diff --git a/src/openedx_tagging/signal_handlers.py b/src/openedx_tagging/signal_handlers.py new file mode 100644 index 000000000..5633fc896 --- /dev/null +++ b/src/openedx_tagging/signal_handlers.py @@ -0,0 +1,32 @@ +"""Signal handlers for tagging-related model updates.""" + +from functools import partial + +from django.db import transaction +from django.db.models.signals import post_save +from django.dispatch import receiver + +from openedx_tagging.models.base import Tag +from openedx_tagging.tasks import emit_content_object_associations_changed_for_tag_task + + +@receiver(post_save, sender=Tag) +def tag_post_save(sender, **kwargs): # pylint: disable=unused-argument + """ + If a tag is updated, enqueue async event emission for all associated objects. + """ + instance = kwargs.get("instance", None) + + if kwargs.get("created", False) or instance is None: + return + + tag_id = instance.id + if tag_id is None: + return + + transaction.on_commit( + partial( + emit_content_object_associations_changed_for_tag_task.delay, + tag_id=tag_id + ) + ) diff --git a/src/openedx_tagging/tasks.py b/src/openedx_tagging/tasks.py new file mode 100644 index 000000000..6621083e2 --- /dev/null +++ b/src/openedx_tagging/tasks.py @@ -0,0 +1,59 @@ +"""Celery tasks for openedx_tagging.""" + +import logging + +from celery import shared_task # type: ignore[import] +from openedx_events.content_authoring.data import ContentObjectChangedData # type: ignore[import-untyped] +from openedx_events.content_authoring.signals import CONTENT_OBJECT_ASSOCIATIONS_CHANGED # type: ignore[import-untyped] + +from openedx_tagging.models.base import ObjectTag, Tag + +logger = logging.getLogger(__name__) + + +def _emit_content_object_associations_changed_for_tag(tag: Tag) -> int: + """ + Emit CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for each content object linked to this tag + via the ObjectTag assciations. This is used to trigger downstream updates + like search index refreshes in Meilisearch. + """ + object_ids = ObjectTag.objects.filter(tag=tag).values_list("object_id", flat=True) + emitted_events = 0 + + for object_id in object_ids.iterator(): + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=object_id, + changes=["tags"], + ), + ) + emitted_events += 1 + + logger.info( + "Tag with id %s was updated. Emitted CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for %s associated objects.", + tag.id, + emitted_events, + ) + return emitted_events + + +@shared_task +def emit_content_object_associations_changed_for_tag_task(tag_id: int) -> int: + """ + When a tag is updated, emit a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for every ObjectTag linked to that tag. + Each ObjectTag represents an association between the tag and an Open edX object. + Because downstream systems (for example, search indexes such as Meilisearch) index object-tag relationships, + they must be notified so they can refresh the object's association data. + """ + try: + tag = Tag.objects.get(pk=tag_id) + except Tag.DoesNotExist: + logger.warning( + "Skipping CONTENT_OBJECT_ASSOCIATIONS_CHANGED emission because tag id %s does not exist.", + tag_id, + ) + return 0 + + return _emit_content_object_associations_changed_for_tag(tag) diff --git a/tests/openedx_tagging/test_models.py b/tests/openedx_tagging/test_models.py index a1dff1c3a..f7b2b9359 100644 --- a/tests/openedx_tagging/test_models.py +++ b/tests/openedx_tagging/test_models.py @@ -4,6 +4,8 @@ from __future__ import annotations +from unittest.mock import MagicMock, patch + import ddt # type: ignore[import] import pytest from django.contrib.auth import get_user_model @@ -15,6 +17,7 @@ from openedx_tagging import api from openedx_tagging.models import LanguageTaxonomy, ObjectTag, Tag, Taxonomy from openedx_tagging.models.utils import RESERVED_TAG_CHARS +from openedx_tagging.tasks import emit_content_object_associations_changed_for_tag_task from .utils import pretty_format_tags @@ -1133,3 +1136,48 @@ def test_rename(self): assert self.charlie.lineage == "Charlie\t" assert self.bob.depth == 1 assert self.bob.lineage == "Charlie\tBob\t" + + @patch("openedx_tagging.signal_handlers.emit_content_object_associations_changed_for_tag_task.delay") + def test_rename_updates_search_index(self, mock_task_delay) -> None: + """ + Renaming a tag should enqueue an async task that emits + CONTENT_OBJECT_ASSOCIATIONS_CHANGED events. + """ + ObjectTag.objects.create( + object_id="content-v1:org+course+run+type@unit+block@123", + taxonomy=self.alice.taxonomy, + tag=self.alice, + ) + + with self.captureOnCommitCallbacks(execute=True): + self.alice.value = "Alicia" + self.alice.save() + + assert mock_task_delay.call_count == 1 + assert mock_task_delay.call_args[1]['tag_id'] == self.alice.id + + @patch("openedx_tagging.tasks.CONTENT_OBJECT_ASSOCIATIONS_CHANGED", new_callable=MagicMock) + def test_emit_content_object_associations_changed_for_tag_task(self, mock_signal) -> None: + """Task emits one CONTENT_OBJECT_ASSOCIATIONS_CHANGED event per associated object.""" + first_object_id = "content-v1:org+course+run+type@unit+block@123" + second_object_id = "content-v1:org+course+run+type@unit+block@124" + ObjectTag.objects.create( + object_id=first_object_id, + taxonomy=self.alice.taxonomy, + tag=self.alice, + ) + ObjectTag.objects.create( + object_id=second_object_id, + taxonomy=self.alice.taxonomy, + tag=self.alice, + ) + + emitted_events = emit_content_object_associations_changed_for_tag_task(self.alice.id) + + assert emitted_events == 2 + assert mock_signal.send_event.call_count == 2 + emitted_object_ids = { + call.kwargs["content_object"].object_id + for call in mock_signal.send_event.call_args_list + } + assert emitted_object_ids == {first_object_id, second_object_id}