Skip to content

Commit e0fa70b

Browse files
committed
temp: merge branch 'main' into kdmccormick/core-1
2 parents 4660206 + 560e231 commit e0fa70b

7 files changed

Lines changed: 256 additions & 63 deletions

File tree

src/openedx_content/applets/publishing/api.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -474,8 +474,6 @@ def publish_from_drafts(
474474
By default, this will also publish all dependencies (e.g. unpinned children)
475475
of the Drafts that are passed in.
476476
"""
477-
if DraftChangeLogContext.get_active_draft_change_log(learning_package_id) is not None:
478-
raise ValidationError("Cannot publish while in bulk_draft_changes_for().")
479477
if published_at is None:
480478
published_at = datetime.now(tz=timezone.utc)
481479

src/openedx_content/migrations/0004_componenttype_constraint.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
1-
# Generated by Django 5.2.11 on 2026-03-02 23:37
2-
31
from django.db import migrations, models
2+
from django.db.models import Count, Min
3+
4+
5+
def consolidate_duplicate_component_types(apps, schema_editor):
6+
"""
7+
Older installations may have multiple ComponentType rows with the same
8+
(namespace, name) due to a missing unique constraint.
9+
10+
Before we apply the constraint, we need to fix the data by removing the
11+
duplicate entries. For each set of duplicates, keep the earliest row (lowest
12+
ID), repoint any Components that referenced the duplicates to it, then
13+
delete the duplicates so the new unique constraint can be applied cleanly.
14+
"""
15+
ComponentType = apps.get_model("openedx_content", "ComponentType")
16+
Component = apps.get_model("openedx_content", "Component")
17+
18+
duplicate_groups = (
19+
ComponentType.objects.values("namespace", "name").annotate(num=Count("id"), keep_id=Min("id")).filter(num__gt=1)
20+
)
21+
22+
for group in duplicate_groups:
23+
keep_id = group["keep_id"]
24+
duplicate_ids = list(
25+
ComponentType.objects.filter(namespace=group["namespace"], name=group["name"])
26+
.exclude(id=keep_id)
27+
.values_list("id", flat=True)
28+
)
29+
Component.objects.filter(component_type_id__in=duplicate_ids).update(
30+
component_type_id=keep_id,
31+
)
32+
ComponentType.objects.filter(id__in=duplicate_ids).delete()
433

534

635
class Migration(migrations.Migration):
@@ -9,6 +38,10 @@ class Migration(migrations.Migration):
938
]
1039

1140
operations = [
41+
migrations.RunPython(
42+
consolidate_duplicate_component_types,
43+
reverse_code=migrations.RunPython.noop,
44+
),
1245
migrations.AddConstraint(
1346
model_name="componenttype",
1447
constraint=models.UniqueConstraint(fields=("namespace", "name"), name="oel_component_type_uniq_ns_n"),

src/openedx_tagging/signal_handlers.py

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,60 @@
33
from functools import partial
44

55
from django.db import transaction
6-
from django.db.models.signals import post_save
6+
from django.db.models import QuerySet
7+
from django.db.models.signals import post_save, pre_delete
78
from django.dispatch import receiver
89

9-
from openedx_tagging.models.base import Tag
10-
from openedx_tagging.tasks import emit_content_object_associations_changed_for_tag_task
10+
from openedx_tagging.models.base import ObjectTag, Tag
11+
from openedx_tagging.tasks import (
12+
emit_content_object_associations_changed_for_object_ids_task,
13+
emit_content_object_associations_changed_for_tag_task,
14+
)
15+
16+
17+
def _is_explicit_tag_delete(
18+
instance: Tag,
19+
origin: Tag | QuerySet[Tag] | None,
20+
using: str | None,
21+
) -> bool:
22+
"""
23+
Return True only for tags explicitly targeted by the delete operation.
24+
25+
Descendants deleted via CASCADE are skipped here because the explicit root
26+
tag's handler emits updates for the whole subtree.
27+
28+
Args:
29+
instance: The Tag being deleted.
30+
origin: The source of the delete operation - either a Tag instance (for instance.delete())
31+
or a QuerySet[Tag] (for queryset.delete()), or None for other origins.
32+
using: The database alias to use for queries, passed from the Django signal.
33+
"""
34+
if isinstance(origin, Tag):
35+
return origin.pk == instance.pk
36+
37+
# Fail fast if origin has an unexpected type so callsites don't silently
38+
# skip event emission logic.
39+
if not isinstance(origin, QuerySet):
40+
raise TypeError(f"Expected origin to be Tag, QuerySet[Tag], or None; got {type(origin).__name__}")
41+
if origin.model is not Tag:
42+
raise TypeError(f"Expected origin queryset model Tag; got {origin.model.__name__}")
43+
44+
# Check if this instance is in the set of explicitly-targeted tags. If not, it's being deleted
45+
# as a CASCADE side-effect, so it's not explicit.
46+
explicit_tags = origin.using(using)
47+
if not explicit_tags.filter(pk=instance.pk).exists():
48+
return False
49+
50+
lineage_parts = instance.get_lineage()
51+
# Build the tab-separated lineage strings for all ancestors to check if any of them are
52+
# also in explicit_tags. If an ancestor was explicitly targeted, then this tag is a CASCADE
53+
# side-effect, not explicitly deleted. For example, if lineage_parts is
54+
# ["root", "parent", "child"], ancestor_lineages will be ["root\t", "root\tparent\t"].
55+
ancestor_lineages = ["\t".join(lineage_parts[:index]) + "\t" for index in range(1, len(lineage_parts))]
56+
if not ancestor_lineages:
57+
return True
58+
59+
return not explicit_tags.filter(lineage__in=ancestor_lineages).exists()
1160

1261

1362
@receiver(post_save, sender=Tag)
@@ -28,5 +77,40 @@ def tag_post_save(sender, **kwargs): # pylint: disable=unused-argument
2877
partial(
2978
emit_content_object_associations_changed_for_tag_task.delay,
3079
tag_id=tag_id
31-
)
80+
),
81+
)
82+
83+
84+
@receiver(pre_delete, sender=Tag)
85+
def tag_pre_delete(sender, **kwargs): # pylint: disable=unused-argument
86+
"""
87+
If a tag is deleted, enqueue async event emission for all associated objects.
88+
"""
89+
instance = kwargs.get("instance", None)
90+
origin = kwargs.get("origin", None)
91+
using = kwargs.get("using", None)
92+
93+
# Return early if the instance is missing or hasn't been saved yet (no ID).
94+
# In these cases, we can't proceed with the signal logic.
95+
if instance is None or instance.id is None:
96+
return
97+
98+
if not _is_explicit_tag_delete(instance, origin, using):
99+
return
100+
101+
object_ids = list(
102+
ObjectTag.objects.using(using)
103+
.filter(tag__lineage__startswith=instance.lineage)
104+
.values_list("object_id", flat=True)
105+
.distinct()
106+
)
107+
if not object_ids:
108+
return
109+
110+
transaction.on_commit(
111+
partial(
112+
emit_content_object_associations_changed_for_object_ids_task.delay,
113+
object_ids=object_ids,
114+
),
115+
using=using,
32116
)

src/openedx_tagging/tasks.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Celery tasks for openedx_tagging."""
22

33
import logging
4+
from collections.abc import Iterable
45

56
from celery import shared_task # type: ignore[import]
67
from openedx_events.content_authoring.data import ContentObjectChangedData # type: ignore[import-untyped]
@@ -11,16 +12,12 @@
1112
logger = logging.getLogger(__name__)
1213

1314

14-
def _emit_content_object_associations_changed_for_tag(tag: Tag) -> int:
15+
def _emit_content_object_associations_changed_for_object_ids(object_ids: Iterable[str]) -> int:
1516
"""
16-
Emit CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for each content object linked to this tag
17-
via the ObjectTag assciations. This is used to trigger downstream updates
18-
like search index refreshes in Meilisearch.
17+
Emit CONTENT_OBJECT_ASSOCIATIONS_CHANGED once for each distinct object ID.
1918
"""
20-
object_ids = ObjectTag.objects.filter(tag=tag).values_list("object_id", flat=True)
2119
emitted_events = 0
22-
23-
for object_id in object_ids.iterator():
20+
for object_id in set(object_ids):
2421
# .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED
2522
# .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1
2623
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event(
@@ -31,6 +28,18 @@ def _emit_content_object_associations_changed_for_tag(tag: Tag) -> int:
3128
)
3229
emitted_events += 1
3330

31+
return emitted_events
32+
33+
34+
def _emit_content_object_associations_changed_for_tag(tag: Tag) -> int:
35+
"""
36+
Emit CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for each content object linked to this tag
37+
via the ObjectTag associations. This is used to trigger downstream updates
38+
like search index refreshes in Meilisearch.
39+
"""
40+
object_ids = ObjectTag.objects.filter(tag=tag).values_list("object_id", flat=True).distinct()
41+
emitted_events = _emit_content_object_associations_changed_for_object_ids(object_ids.iterator())
42+
3443
logger.info(
3544
"Tag with id %s was updated. Emitted CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for %s associated objects.",
3645
tag.id,
@@ -57,3 +66,17 @@ def emit_content_object_associations_changed_for_tag_task(tag_id: int) -> int:
5766
return 0
5867

5968
return _emit_content_object_associations_changed_for_tag(tag)
69+
70+
71+
@shared_task
72+
def emit_content_object_associations_changed_for_object_ids_task(object_ids: list[str]) -> int:
73+
"""
74+
Emit CONTENT_OBJECT_ASSOCIATIONS_CHANGED events for content objects whose
75+
tag associations changed because one or more tags were deleted.
76+
"""
77+
emitted_events = _emit_content_object_associations_changed_for_object_ids(object_ids)
78+
logger.info(
79+
"Deleted tag(s) affected %s associated objects. Emitted CONTENT_OBJECT_ASSOCIATIONS_CHANGED events.",
80+
emitted_events,
81+
)
82+
return emitted_events

tests/openedx_content/applets/publishing/test_api.py

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2533,39 +2533,3 @@ def test_create_version_rejects_cross_package_dependencies(self) -> None:
25332533
created_by=None,
25342534
dependencies=[entity_in_lp2.id],
25352535
)
2536-
2537-
def test_publish_functions_rejected_inside_bulk_draft_changes_for(self) -> None:
2538-
"""
2539-
publish_all_drafts() and publish_from_drafts() must not be callable
2540-
from within a bulk_draft_changes_for() context.
2541-
2542-
bulk_draft_changes_for() opens a DraftChangeLog for accumulating draft
2543-
edits; running a publish inside it mixes draft-change bookkeeping with
2544-
publish bookkeeping in the same atomic block, which corrupts the
2545-
ordering of DraftChangeLog vs. PublishLog records and can leave Drafts
2546-
and Published rows out of sync if the outer context later raises.
2547-
"""
2548-
entity = publishing_api.create_publishable_entity(
2549-
self.learning_package_1.id,
2550-
"entity_for_bulk_publish_check",
2551-
created=self.now,
2552-
created_by=None,
2553-
)
2554-
publishing_api.create_publishable_entity_version(
2555-
entity.id,
2556-
version_num=1,
2557-
title="Entity v1",
2558-
created=self.now,
2559-
created_by=None,
2560-
)
2561-
2562-
with pytest.raises(ValidationError, match="Cannot publish while in bulk_draft_changes_for()."):
2563-
with publishing_api.bulk_draft_changes_for(self.learning_package_1.id):
2564-
publishing_api.publish_all_drafts(self.learning_package_1.id)
2565-
2566-
with pytest.raises(ValidationError, match="Cannot publish while in bulk_draft_changes_for()."):
2567-
with publishing_api.bulk_draft_changes_for(self.learning_package_1.id):
2568-
publishing_api.publish_from_drafts(
2569-
self.learning_package_1.id,
2570-
Draft.objects.filter(entity__learning_package_id=self.learning_package_1.id),
2571-
)

tests/openedx_tagging/test_api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import annotations
55

66
from typing import Any
7+
from unittest.mock import patch
78

89
import ddt # type: ignore[import]
910
import pytest
@@ -741,7 +742,8 @@ def get_object_tags():
741742
# Now delete and disable things:
742743
disabled_taxonomy.enabled = False
743744
disabled_taxonomy.save()
744-
self.free_text_taxonomy.delete()
745+
with patch("openedx_tagging.signal_handlers._is_explicit_tag_delete", return_value=False):
746+
self.free_text_taxonomy.delete()
745747
tagging_api.delete_tags_from_taxonomy(self.taxonomy, ["DPANN"], with_subtags=False)
746748

747749
# Now retrieve the tags again:

0 commit comments

Comments
 (0)