Skip to content

Commit 87f845c

Browse files
feat: add a pre_save hook to prevent Organization.short_name from being changed
1 parent bfdfece commit 87f845c

File tree

3 files changed

+105
-1
lines changed

3 files changed

+105
-1
lines changed

src/openedx_catalog/apps.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,6 @@ class CatalogAppConfig(AppConfig):
1616
label = "openedx_catalog"
1717

1818
def ready(self) -> None:
19-
pass
19+
20+
# Connect signal handlers.
21+
from . import signals # pylint: disable=import-outside-toplevel,unused-import

src/openedx_catalog/signals.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""
2+
Signal receivers for openedx_catalog
3+
"""
4+
# pylint: disable=unused-argument
5+
6+
import logging
7+
8+
from django.core.exceptions import ValidationError
9+
from django.db.models.signals import pre_save
10+
from django.dispatch import receiver
11+
12+
from organizations.models import Organization
13+
14+
from .models import CourseRun
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
@receiver(pre_save, sender=Organization)
20+
def verify_organization_change(sender, instance, **kwargs):
21+
"""
22+
Check that changes to Organization objects won't create invalid relationships.
23+
24+
Nothing stops users from changing Organization.short_name entries in the Django admin, but any changes other than
25+
capitalization fixes will result in totally invalid data, as CatalogCourse will be related to an Organization that
26+
no longer matches the related course's course IDs.
27+
"""
28+
# Check only at User creation time and when not raw.
29+
if not instance.pk:
30+
return # It's a brand new Organization; we don't care
31+
32+
prev_org_code = Organization.objects.get(pk=instance.pk).short_name
33+
new_org_code = instance.short_name
34+
35+
if new_org_code != prev_org_code:
36+
# Check if this is going to violate any relationship expectations.
37+
# Note: we could make the database enforce this by making the CatalogCourse.org relationship use "short_name" as
38+
# its foreign key ID, but that would also make it extremely difficult to ever "fix" an incorrect Organization's
39+
# short_name (e.g. change capitalization), because doing so would fail with a foreign key constraint error.
40+
41+
run_course_ids = CourseRun.objects.filter(catalog_course__org=instance).values_list("course_id", flat=True)
42+
for course_id in run_course_ids:
43+
if course_id.org.lower() != new_org_code.lower():
44+
raise ValidationError(
45+
f'Changing the org short_name to "{new_org_code}" will result in CourseRun "{course_id}" having '
46+
"the incorrect organization code. "
47+
)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""
2+
Tests related to the Catalog signal handlers
3+
"""
4+
5+
import re
6+
7+
from django.core.exceptions import ValidationError
8+
from django.test import TestCase
9+
from organizations.api import ensure_organization # type: ignore[import]
10+
from organizations.models import Organization
11+
import pytest
12+
13+
from openedx_catalog.models import CatalogCourse, CourseRun
14+
15+
16+
class TestCatalogSignals(TestCase):
17+
"""
18+
Test openedx_catalog signal handlers
19+
"""
20+
21+
def test_org_integrity(self) -> None:
22+
"""
23+
Test that Organization.short_name cannot be changed if it would result in invalid CourseRun relationships.
24+
25+
Note: this is just Django validation; running an `update()` or raw SQL will easily bypass this check.
26+
"""
27+
org = Organization.objects.get(pk=ensure_organization("Org1")["id"])
28+
29+
catalog_course = CatalogCourse.objects.create(org=org, course_code="Math100")
30+
course_run = CourseRun.objects.create(catalog_course=catalog_course, run="A")
31+
assert str(course_run.course_id) == "course-v1:Org1+Math100+A"
32+
33+
org.short_name = "foo"
34+
with pytest.raises(
35+
ValidationError,
36+
match=re.escape(
37+
'Changing the org short_name to "foo" will result in CourseRun "course-v1:Org1+Math100+A" having '
38+
"the incorrect organization code."
39+
),
40+
):
41+
org.save()
42+
43+
# BUT, changing just the capitalization is allowed:
44+
org.short_name = "orG1"
45+
org.save() # No ValidationError
46+
47+
def test_org_short_name_change_no_runs(self) -> None:
48+
"""
49+
Test that Organization.short_name CAN be changed if it won't affect any course runs.
50+
"""
51+
org = Organization.objects.get(pk=ensure_organization("Org1")["id"])
52+
CatalogCourse.objects.create(org=org, course_code="Math100")
53+
54+
org.short_name = "foo"
55+
org.save()

0 commit comments

Comments
 (0)