From efe1333e43bd50b11075fe2713c052c0d8115ef4 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 6 Jan 2025 10:09:37 +0530 Subject: [PATCH 1/7] feat: model for upstream-downstream links --- openedx_learning/api/authoring.py | 1 + openedx_learning/api/authoring_models.py | 1 + .../apps/authoring/linking/__init__.py | 0 .../apps/authoring/linking/admin.py | 53 +++++++ .../apps/authoring/linking/api.py | 103 ++++++++++++++ .../apps/authoring/linking/apps.py | 16 +++ .../authoring/linking/management/__init__.py | 0 .../linking/management/commands/__init__.py | 0 .../linking/migrations/0001_initial.py | 133 ++++++++++++++++++ .../authoring/linking/migrations/__init__.py | 0 .../apps/authoring/linking/models.py | 124 ++++++++++++++++ projects/dev.py | 1 + test_settings.py | 1 + 13 files changed, 433 insertions(+) create mode 100644 openedx_learning/apps/authoring/linking/__init__.py create mode 100644 openedx_learning/apps/authoring/linking/admin.py create mode 100644 openedx_learning/apps/authoring/linking/api.py create mode 100644 openedx_learning/apps/authoring/linking/apps.py create mode 100644 openedx_learning/apps/authoring/linking/management/__init__.py create mode 100644 openedx_learning/apps/authoring/linking/management/commands/__init__.py create mode 100644 openedx_learning/apps/authoring/linking/migrations/0001_initial.py create mode 100644 openedx_learning/apps/authoring/linking/migrations/__init__.py create mode 100644 openedx_learning/apps/authoring/linking/models.py diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index 1da667af..5e485210 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -13,6 +13,7 @@ from ..apps.authoring.components.api import * from ..apps.authoring.contents.api import * from ..apps.authoring.publishing.api import * +from ..apps.authoring.linking.api import * # This was renamed after the authoring API refactoring pushed this and other # app APIs into the openedx_learning.api.authoring module. Here I'm aliasing to diff --git a/openedx_learning/api/authoring_models.py b/openedx_learning/api/authoring_models.py index fb0ab669..814a525e 100644 --- a/openedx_learning/api/authoring_models.py +++ b/openedx_learning/api/authoring_models.py @@ -12,3 +12,4 @@ from ..apps.authoring.contents.models import * from ..apps.authoring.publishing.model_mixins import * from ..apps.authoring.publishing.models import * +from ..apps.authoring.linking.models import * diff --git a/openedx_learning/apps/authoring/linking/__init__.py b/openedx_learning/apps/authoring/linking/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/linking/admin.py b/openedx_learning/apps/authoring/linking/admin.py new file mode 100644 index 00000000..f80a798e --- /dev/null +++ b/openedx_learning/apps/authoring/linking/admin.py @@ -0,0 +1,53 @@ +""" +Django admin for linking models +""" + +from django.contrib import admin + +from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin + +from .models import PublishableEntityLink, CourseLinksStatus + + +@admin.register(PublishableEntityLink) +class PublishableEntityLinkAdmin(admin.ModelAdmin): + fields = [ + "uuid", + "upstream_block", + "upstream_usage_key", + "upstream_context_key", + "downstream_usage_key", + "downstream_context_key", + "downstream_context_title", + "version_synced", + "version_declined", + "created", + "updated", + ] + readonly_fields = fields + list_display = [ + "upstream_block", + "upstream_usage_key", + "downstream_usage_key", + "downstream_context_title", + "version_synced", + "updated", + ] + search_fields = [ + "upstream_usage_key", + "upstream_context_key", + "downstream_usage_key", + "downstream_context_key", + "downstream_context_title", + ] + + +@admin.register(CourseLinksStatus) +class CourseLinksStatusAdmin(admin.ModelAdmin): + fields = ( + "context_key", + "status", + "created", + "updated", + ) + readonly_fields = ("created", "updated") diff --git a/openedx_learning/apps/authoring/linking/api.py b/openedx_learning/apps/authoring/linking/api.py new file mode 100644 index 00000000..d4685b5c --- /dev/null +++ b/openedx_learning/apps/authoring/linking/api.py @@ -0,0 +1,103 @@ +""" +Linking API (warning: UNSTABLE, in progress API) + +Please look at the models.py file for more information about the kinds of data +are stored in this app. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from django.db.models import QuerySet + +from ..components.models import Component +from .models import CourseLinksStatus, CourseLinksStatusChoices, PublishableEntityLink + +__all__ = [ + "delete_entity_link", + "get_entity_links", + "get_or_create_course_link_status", + "update_or_create_entity_link", +] + + +def get_or_create_course_link_status(context_key: str, created: datetime | None = None) -> CourseLinksStatus: + """ + Get or create course link status row from CourseLinksStatus table for given course key. + + Args: + context_key: Course key + + Returns: + CourseLinksStatus object + """ + if not created: + created = datetime.now(tz=timezone.utc) + status, _ = CourseLinksStatus.objects.get_or_create( + context_key=context_key, + defaults={ + "status": CourseLinksStatusChoices.PENDING, + "created": created, + "updated": created, + } + ) + return status + + +def get_entity_links(filters: dict[str, Any]) -> QuerySet[PublishableEntityLink]: + """ + Get entity links based on passed filters. + """ + return PublishableEntityLink.objects.filter(**filters) + + +def update_or_create_entity_link( + upstream_block: Component, + /, + upstream_usage_key: str, + downstream_usage_key: str, + downstream_context_key: str, + downstream_context_title: str, + version_synced: int, + version_declined: int | None = None, + created: datetime | None = None, +) -> PublishableEntityLink: + """ + Update or create entity link. This will only update `updated` field if something has changed. + """ + if not created: + created = datetime.now(tz=timezone.utc) + new_values = { + "upstream_block": upstream_block.publishable_entity, + "upstream_usage_key": upstream_usage_key, + "upstream_context_key": upstream_block.learning_package.key, + "downstream_usage_key": downstream_usage_key, + "downstream_context_key": downstream_context_key, + "downstream_context_title": downstream_context_title, + "version_synced": version_synced, + "version_declined": version_declined, + } + try: + link = PublishableEntityLink.objects.get(downstream_usage_key=downstream_usage_key) + has_changes = False + for key, value in new_values.items(): + prev = getattr(link, key) + # None != None is True, so we need to check for it specially + if prev != value and ~(prev is None and value is None): + has_changes = True + setattr(link, key, value) + if has_changes: + link.updated = created + link.save() + except PublishableEntityLink.DoesNotExist: + link = PublishableEntityLink(**new_values) + link.created = created + link.updated = created + link.save() + return link + + +def delete_entity_link(downstream_usage_key: str): + """Detele upstream->downstream entity link from database""" + PublishableEntityLink.objects.filter(downstream_usage_key=downstream_usage_key).delete() diff --git a/openedx_learning/apps/authoring/linking/apps.py b/openedx_learning/apps/authoring/linking/apps.py new file mode 100644 index 00000000..a89be022 --- /dev/null +++ b/openedx_learning/apps/authoring/linking/apps.py @@ -0,0 +1,16 @@ +""" +linking Django application initialization. +""" + +from django.apps import AppConfig + + +class LinkingConfig(AppConfig): + """ + Configuration for the linking Django application. + """ + + name = "openedx_learning.apps.authoring.linking" + verbose_name = "Learning Core > Authoring > Linking" + default_auto_field = "django.db.models.BigAutoField" + label = "oel_linking" diff --git a/openedx_learning/apps/authoring/linking/management/__init__.py b/openedx_learning/apps/authoring/linking/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/linking/management/commands/__init__.py b/openedx_learning/apps/authoring/linking/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/linking/migrations/0001_initial.py b/openedx_learning/apps/authoring/linking/migrations/0001_initial.py new file mode 100644 index 00000000..fa0366e9 --- /dev/null +++ b/openedx_learning/apps/authoring/linking/migrations/0001_initial.py @@ -0,0 +1,133 @@ +# Generated by Django 4.2.17 on 2025-01-07 12:17 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import openedx_learning.lib.fields +import openedx_learning.lib.validators + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ('oel_publishing', '0002_alter_learningpackage_key_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='CourseLinksStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ( + 'context_key', + openedx_learning.lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, + help_text='Linking status for downstream/course context key', + max_length=500, + ), + ), + ( + 'status', + models.CharField( + choices=[ + ('pending', 'Pending'), + ('processing', 'Processing'), + ('failed', 'Failed'), + ('completed', 'Completed'), + ], + help_text='Status of links in given course.', + max_length=20, + ), + ), + ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ], + options={ + 'verbose_name': 'Course Links status', + 'verbose_name_plural': 'Course Links status', + }, + ), + migrations.CreateModel( + name='PublishableEntityLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), + ( + 'upstream_usage_key', + openedx_learning.lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, + help_text=( + 'Upstream block usage key, this value cannot be null and useful to track upstream' + ' library blocks that do not exist yet' + ), + max_length=500, + ), + ), + ( + 'upstream_context_key', + openedx_learning.lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, + help_text='Upstream context key i.e., learning_package/library key', + max_length=500, + ), + ), + ( + 'downstream_usage_key', + openedx_learning.lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=500 + ), + ), + ( + 'downstream_context_key', + openedx_learning.lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, max_length=500 + ), + ), + ( + 'downstream_context_title', + openedx_learning.lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, + help_text='The title of downstream context, for example, course display name.', + max_length=1000, + ), + ), + ('version_synced', models.IntegerField()), + ('version_declined', models.IntegerField(blank=True, null=True)), + ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ( + 'upstream_block', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='links', + to='oel_publishing.publishableentity', + ), + ), + ], + options={ + 'verbose_name': 'Publishable Entity Link', + 'verbose_name_plural': 'Publishable Entity Links', + }, + ), + migrations.AddConstraint( + model_name='courselinksstatus', + constraint=models.UniqueConstraint(fields=('context_key',), name='oel_link_ent_status_ctx_key'), + ), + migrations.AddIndex( + model_name='publishableentitylink', + index=models.Index(fields=['downstream_context_key'], name='oel_link_ent_idx_down_ctx_key'), + ), + migrations.AddIndex( + model_name='publishableentitylink', + index=models.Index(fields=['upstream_context_key'], name='oel_link_ent_idx_up_ctx_key'), + ), + migrations.AddConstraint( + model_name='publishableentitylink', + constraint=models.UniqueConstraint(fields=('downstream_usage_key',), name='oel_link_ent_downstream_key'), + ), + ] diff --git a/openedx_learning/apps/authoring/linking/migrations/__init__.py b/openedx_learning/apps/authoring/linking/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/linking/models.py b/openedx_learning/apps/authoring/linking/models.py new file mode 100644 index 00000000..6125df50 --- /dev/null +++ b/openedx_learning/apps/authoring/linking/models.py @@ -0,0 +1,124 @@ +""" +Contains models to store upstream-downstream links between publishable entities. +""" +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from openedx_learning.lib.fields import ( + case_insensitive_char_field, + immutable_uuid_field, + key_field, + manual_date_time_field, +) + +from ..publishing.models import PublishableEntity + +__all__ = [ + "PublishableEntityLink", + "CourseLinksStatus", + "CourseLinksStatusChoices" +] + + +class PublishableEntityLink(models.Model): + """ + This represents link between any two publishable entities or link between publishable entity and a course + xblock. It helps in tracking relationship between xblocks imported from libraries and used in different courses. + """ + uuid = immutable_uuid_field() + upstream_block = models.ForeignKey( + PublishableEntity, + on_delete=models.SET_NULL, + related_name="links", + null=True, + blank=True, + ) + upstream_usage_key = key_field( + help_text=_( + "Upstream block usage key, this value cannot be null" + " and useful to track upstream library blocks that do not exist yet" + ) + ) + upstream_context_key = key_field( + help_text=_( + "Upstream context key i.e., learning_package/library key" + ) + ) + downstream_usage_key = key_field() + downstream_context_key = key_field() + downstream_context_title = case_insensitive_char_field( + null=False, + blank=False, + max_length=1000, + help_text=_( + "The title of downstream context, for example, course display name." + ), + ) + version_synced = models.IntegerField() + version_declined = models.IntegerField(null=True, blank=True) + created = manual_date_time_field() + updated = manual_date_time_field() + + def __str__(self): + return f"{self.upstream_usage_key}->{self.downstream_usage_key}" + + class Meta: + constraints = [ + # A downstream entity can only link to single upstream entity + # whereas an entity can be upstream for multiple downstream entities. + models.UniqueConstraint( + fields=["downstream_usage_key"], + name="oel_link_ent_downstream_key", + ) + ] + indexes = [ + # Search by course/downstream key + models.Index( + fields=["downstream_context_key"], + name="oel_link_ent_idx_down_ctx_key", + ), + # Search by library/upstream key + models.Index( + fields=["upstream_context_key"], + name="oel_link_ent_idx_up_ctx_key" + ), + ] + verbose_name = "Publishable Entity Link" + verbose_name_plural = "Publishable Entity Links" + + +class CourseLinksStatusChoices(models.TextChoices): + """ + Enumerates the states that a CourseLinksStatus can be in. + """ + PENDING = "pending", _("Pending") + PROCESSING = "processing", _("Processing") + FAILED = "failed", _("Failed") + COMPLETED = "completed", _("Completed") + + +class CourseLinksStatus(models.Model): + context_key = key_field( + help_text=_("Linking status for downstream/course context key"), + ) + status = models.CharField( + max_length=20, + choices=CourseLinksStatusChoices.choices, + help_text=_("Status of links in given course."), + ) + created = manual_date_time_field() + updated = manual_date_time_field() + + class Meta: + constraints = [ + # Single entry for a course + models.UniqueConstraint( + fields=["context_key"], + name="oel_link_ent_status_ctx_key", + ) + ] + verbose_name = "Course Links status" + verbose_name_plural = "Course Links status" + + def __str__(self): + return f"{self.status}|{self.context_key}" diff --git a/projects/dev.py b/projects/dev.py index ada76e5e..95ab1d44 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -35,6 +35,7 @@ "openedx_learning.apps.authoring.components.apps.ComponentsConfig", "openedx_learning.apps.authoring.contents.apps.ContentsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", + "openedx_learning.apps.authoring.linking.apps.LinkingConfig", # Learning Contrib Apps "openedx_learning.contrib.media_server.apps.MediaServerConfig", # Apps that don't belong in this repo in the long term, but are here to make diff --git a/test_settings.py b/test_settings.py index f5b154f5..d18cd5fd 100644 --- a/test_settings.py +++ b/test_settings.py @@ -44,6 +44,7 @@ def root(*args): "openedx_learning.apps.authoring.components.apps.ComponentsConfig", "openedx_learning.apps.authoring.contents.apps.ContentsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", + "openedx_learning.apps.authoring.linking.apps.LinkingConfig", "openedx_tagging.core.tagging.apps.TaggingConfig", ] From d9612abb170a15c55792c9fd871d7bbd89b9b2e2 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 15 Jan 2025 10:17:27 +0530 Subject: [PATCH 2/7] test: upstream-downstream api --- .../apps/authoring/linking/__init__.py | 0 .../apps/authoring/linking/test_api.py | 104 ++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/openedx_learning/apps/authoring/linking/__init__.py create mode 100644 tests/openedx_learning/apps/authoring/linking/test_api.py diff --git a/tests/openedx_learning/apps/authoring/linking/__init__.py b/tests/openedx_learning/apps/authoring/linking/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/openedx_learning/apps/authoring/linking/test_api.py b/tests/openedx_learning/apps/authoring/linking/test_api.py new file mode 100644 index 00000000..9ce1bbe4 --- /dev/null +++ b/tests/openedx_learning/apps/authoring/linking/test_api.py @@ -0,0 +1,104 @@ +""" +Tests of the Linking app's python API +""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from django.core.exceptions import ValidationError + +from openedx_learning.apps.authoring.components import api as components_api +from openedx_learning.apps.authoring.linking import api as linking_api +from openedx_learning.apps.authoring.linking.models import CourseLinksStatus, PublishableEntityLink +from openedx_learning.apps.authoring.publishing import api as publishing_api +from openedx_learning.apps.authoring.publishing.models import LearningPackage +from openedx_learning.apps.authoring.components.models import Component, ComponentType +from openedx_learning.lib.test_utils import TestCase + + +class EntityLinkingTestCase(TestCase): + """ + Entity linking tests. + """ + learning_package: LearningPackage + now: datetime + html_type: ComponentType + html_component: Component + + @classmethod + def setUpTestData(cls) -> None: + cls.learning_package = publishing_api.create_learning_package( + key="EntityLinkingTestCase-test-key", + title="EntityLinking Test Case Learning Package", + ) + cls.now = datetime(2023, 5, 8, tzinfo=timezone.utc) + cls.html_type = components_api.get_or_create_component_type("xblock.v1", "html") + cls.html_component, _ = components_api.create_component_and_version( + cls.learning_package.id, + component_type=cls.html_type, + local_key="html_component", + title="HTML 1", + created=cls.now, + created_by=None, + ) + + def test_get_or_create_course_link_status(self) -> None: + """ + Test get_or_create_course_link_status api. + """ + context_key = "test_context_key" + assert not CourseLinksStatus.objects.filter(context_key=context_key).exists() + linking_api.get_or_create_course_link_status(context_key) + assert CourseLinksStatus.objects.filter(context_key=context_key).exists() + assert CourseLinksStatus.objects.filter(context_key=context_key).count() == 1 + # Should not create a new object + linking_api.get_or_create_course_link_status(context_key) + assert CourseLinksStatus.objects.filter(context_key=context_key).count() == 1 + + def test_update_or_create_entity_link(self) -> None: + """ + Test update_or_create_entity_link. + """ + downstream_usage_key = "test_downstream_usage_key" + assert not PublishableEntityLink.objects.filter(downstream_usage_key=downstream_usage_key).exists() + entity_args = { + "upstream_usage_key": "test_upstream_usage_key", + "downstream_usage_key": downstream_usage_key, + "downstream_context_key": "test_downstream_context_key", + "downstream_context_title": "test_downstream_context_key", + "version_synced": 1, + } + # Should create new link + link = linking_api.update_or_create_entity_link(self.html_component, **entity_args) + assert PublishableEntityLink.objects.filter(downstream_usage_key=downstream_usage_key).exists() + prev_updated_time = link.updated + # Using the api with same arguments should not make any changes + link = linking_api.update_or_create_entity_link(self.html_component, **entity_args) + assert link.updated == prev_updated_time + # update version_synced field + link = linking_api.update_or_create_entity_link( + self.html_component, + **{**entity_args, "version_synced": 2} + ) + assert link.updated != prev_updated_time + assert link.version_synced == 2 + + def test_delete_entity_link(self) -> None: + """ + Test delete entity link by downstream_usage_key + """ + downstream_usage_key = "test_downstream_usage_key" + entity_args = { + "upstream_usage_key": "test_upstream_usage_key", + "downstream_usage_key": downstream_usage_key, + "downstream_context_key": "test_downstream_context_key", + "downstream_context_title": "test_downstream_context_key", + "version_synced": 1, + } + # Should create new link + linking_api.update_or_create_entity_link(self.html_component, **entity_args) + assert PublishableEntityLink.objects.filter(downstream_usage_key=downstream_usage_key).exists() + linking_api.delete_entity_link(downstream_usage_key) + assert not PublishableEntityLink.objects.filter(downstream_usage_key=downstream_usage_key).exists() From 76aa148fec44a90253196940485dd0c3a37b22a5 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 15 Jan 2025 20:17:24 +0530 Subject: [PATCH 3/7] refactor: admin page for links --- openedx_learning/apps/authoring/linking/admin.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openedx_learning/apps/authoring/linking/admin.py b/openedx_learning/apps/authoring/linking/admin.py index f80a798e..c7c964eb 100644 --- a/openedx_learning/apps/authoring/linking/admin.py +++ b/openedx_learning/apps/authoring/linking/admin.py @@ -6,11 +6,11 @@ from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin -from .models import PublishableEntityLink, CourseLinksStatus +from .models import CourseLinksStatus, PublishableEntityLink @admin.register(PublishableEntityLink) -class PublishableEntityLinkAdmin(admin.ModelAdmin): +class PublishableEntityLinkAdmin(ReadOnlyModelAdmin): fields = [ "uuid", "upstream_block", @@ -51,3 +51,9 @@ class CourseLinksStatusAdmin(admin.ModelAdmin): "updated", ) readonly_fields = ("created", "updated") + list_display = ( + "context_key", + "status", + "created", + "updated", + ) From 4a9c23ddb38baf457f08ee261d2914d08dbb69c4 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 15 Jan 2025 20:22:23 +0530 Subject: [PATCH 4/7] chore: fix lint issues --- openedx_learning/api/authoring.py | 2 +- openedx_learning/api/authoring_models.py | 2 +- openedx_learning/apps/authoring/linking/admin.py | 10 ++++++++-- openedx_learning/apps/authoring/linking/api.py | 2 +- openedx_learning/apps/authoring/linking/models.py | 4 ++++ .../apps/authoring/linking/test_api.py | 14 +++++--------- 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index 5e485210..5ec04854 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -12,8 +12,8 @@ from ..apps.authoring.collections.api import * from ..apps.authoring.components.api import * from ..apps.authoring.contents.api import * -from ..apps.authoring.publishing.api import * from ..apps.authoring.linking.api import * +from ..apps.authoring.publishing.api import * # This was renamed after the authoring API refactoring pushed this and other # app APIs into the openedx_learning.api.authoring module. Here I'm aliasing to diff --git a/openedx_learning/api/authoring_models.py b/openedx_learning/api/authoring_models.py index 814a525e..f3428636 100644 --- a/openedx_learning/api/authoring_models.py +++ b/openedx_learning/api/authoring_models.py @@ -10,6 +10,6 @@ from ..apps.authoring.collections.models import * from ..apps.authoring.components.models import * from ..apps.authoring.contents.models import * +from ..apps.authoring.linking.models import * from ..apps.authoring.publishing.model_mixins import * from ..apps.authoring.publishing.models import * -from ..apps.authoring.linking.models import * diff --git a/openedx_learning/apps/authoring/linking/admin.py b/openedx_learning/apps/authoring/linking/admin.py index c7c964eb..5e2f8f9a 100644 --- a/openedx_learning/apps/authoring/linking/admin.py +++ b/openedx_learning/apps/authoring/linking/admin.py @@ -11,7 +11,10 @@ @admin.register(PublishableEntityLink) class PublishableEntityLinkAdmin(ReadOnlyModelAdmin): - fields = [ + """ + PublishableEntityLink admin. + """ + fields = ( "uuid", "upstream_block", "upstream_usage_key", @@ -23,7 +26,7 @@ class PublishableEntityLinkAdmin(ReadOnlyModelAdmin): "version_declined", "created", "updated", - ] + ) readonly_fields = fields list_display = [ "upstream_block", @@ -44,6 +47,9 @@ class PublishableEntityLinkAdmin(ReadOnlyModelAdmin): @admin.register(CourseLinksStatus) class CourseLinksStatusAdmin(admin.ModelAdmin): + """ + CourseLinksStatus admin. + """ fields = ( "context_key", "status", diff --git a/openedx_learning/apps/authoring/linking/api.py b/openedx_learning/apps/authoring/linking/api.py index d4685b5c..51a02de1 100644 --- a/openedx_learning/apps/authoring/linking/api.py +++ b/openedx_learning/apps/authoring/linking/api.py @@ -53,7 +53,7 @@ def get_entity_links(filters: dict[str, Any]) -> QuerySet[PublishableEntityLink] def update_or_create_entity_link( - upstream_block: Component, + upstream_block: Component | None, /, upstream_usage_key: str, downstream_usage_key: str, diff --git a/openedx_learning/apps/authoring/linking/models.py b/openedx_learning/apps/authoring/linking/models.py index 6125df50..5b3e8374 100644 --- a/openedx_learning/apps/authoring/linking/models.py +++ b/openedx_learning/apps/authoring/linking/models.py @@ -98,6 +98,10 @@ class CourseLinksStatusChoices(models.TextChoices): class CourseLinksStatus(models.Model): + """ + This table stores current processing status of upstream-downstream links in PublishableEntityLink table for a + course. + """ context_key = key_field( help_text=_("Linking status for downstream/course context key"), ) diff --git a/tests/openedx_learning/apps/authoring/linking/test_api.py b/tests/openedx_learning/apps/authoring/linking/test_api.py index 9ce1bbe4..92a50dae 100644 --- a/tests/openedx_learning/apps/authoring/linking/test_api.py +++ b/tests/openedx_learning/apps/authoring/linking/test_api.py @@ -4,17 +4,13 @@ from __future__ import annotations from datetime import datetime, timezone -from uuid import UUID - -import pytest -from django.core.exceptions import ValidationError from openedx_learning.apps.authoring.components import api as components_api +from openedx_learning.apps.authoring.components.models import Component, ComponentType from openedx_learning.apps.authoring.linking import api as linking_api from openedx_learning.apps.authoring.linking.models import CourseLinksStatus, PublishableEntityLink from openedx_learning.apps.authoring.publishing import api as publishing_api from openedx_learning.apps.authoring.publishing.models import LearningPackage -from openedx_learning.apps.authoring.components.models import Component, ComponentType from openedx_learning.lib.test_utils import TestCase @@ -71,16 +67,16 @@ def test_update_or_create_entity_link(self) -> None: "version_synced": 1, } # Should create new link - link = linking_api.update_or_create_entity_link(self.html_component, **entity_args) + link = linking_api.update_or_create_entity_link(self.html_component, **entity_args) # type: ignore[arg-type] assert PublishableEntityLink.objects.filter(downstream_usage_key=downstream_usage_key).exists() prev_updated_time = link.updated # Using the api with same arguments should not make any changes - link = linking_api.update_or_create_entity_link(self.html_component, **entity_args) + link = linking_api.update_or_create_entity_link(self.html_component, **entity_args) # type: ignore[arg-type] assert link.updated == prev_updated_time # update version_synced field link = linking_api.update_or_create_entity_link( self.html_component, - **{**entity_args, "version_synced": 2} + **{**entity_args, "version_synced": 2} # type: ignore[arg-type] ) assert link.updated != prev_updated_time assert link.version_synced == 2 @@ -98,7 +94,7 @@ def test_delete_entity_link(self) -> None: "version_synced": 1, } # Should create new link - linking_api.update_or_create_entity_link(self.html_component, **entity_args) + linking_api.update_or_create_entity_link(self.html_component, **entity_args) # type: ignore[arg-type] assert PublishableEntityLink.objects.filter(downstream_usage_key=downstream_usage_key).exists() linking_api.delete_entity_link(downstream_usage_key) assert not PublishableEntityLink.objects.filter(downstream_usage_key=downstream_usage_key).exists() From 64a11cfc3ff81a44a2c830e2f5f7b58fb87e9447 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 16 Jan 2025 11:50:05 +0530 Subject: [PATCH 5/7] refactor: upstream context key field and api --- openedx_learning/apps/authoring/linking/api.py | 7 +++++-- .../apps/authoring/linking/migrations/0001_initial.py | 2 ++ openedx_learning/apps/authoring/linking/models.py | 8 ++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/openedx_learning/apps/authoring/linking/api.py b/openedx_learning/apps/authoring/linking/api.py index 51a02de1..b8d79ad9 100644 --- a/openedx_learning/apps/authoring/linking/api.py +++ b/openedx_learning/apps/authoring/linking/api.py @@ -69,15 +69,18 @@ def update_or_create_entity_link( if not created: created = datetime.now(tz=timezone.utc) new_values = { - "upstream_block": upstream_block.publishable_entity, "upstream_usage_key": upstream_usage_key, - "upstream_context_key": upstream_block.learning_package.key, "downstream_usage_key": downstream_usage_key, "downstream_context_key": downstream_context_key, "downstream_context_title": downstream_context_title, "version_synced": version_synced, "version_declined": version_declined, } + if upstream_block: + new_values.update({ + "upstream_block": upstream_block.publishable_entity, + "upstream_context_key": upstream_block.learning_package.key, + }) try: link = PublishableEntityLink.objects.get(downstream_usage_key=downstream_usage_key) has_changes = False diff --git a/openedx_learning/apps/authoring/linking/migrations/0001_initial.py b/openedx_learning/apps/authoring/linking/migrations/0001_initial.py index fa0366e9..013fd9e8 100644 --- a/openedx_learning/apps/authoring/linking/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/linking/migrations/0001_initial.py @@ -69,9 +69,11 @@ class Migration(migrations.Migration): ( 'upstream_context_key', openedx_learning.lib.fields.MultiCollationCharField( + blank=True, db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, help_text='Upstream context key i.e., learning_package/library key', max_length=500, + null=True, ), ), ( diff --git a/openedx_learning/apps/authoring/linking/models.py b/openedx_learning/apps/authoring/linking/models.py index 5b3e8374..5156228d 100644 --- a/openedx_learning/apps/authoring/linking/models.py +++ b/openedx_learning/apps/authoring/linking/models.py @@ -6,6 +6,7 @@ from openedx_learning.lib.fields import ( case_insensitive_char_field, + case_sensitive_char_field, immutable_uuid_field, key_field, manual_date_time_field, @@ -39,10 +40,13 @@ class PublishableEntityLink(models.Model): " and useful to track upstream library blocks that do not exist yet" ) ) - upstream_context_key = key_field( + upstream_context_key = case_sensitive_char_field( + max_length=500, help_text=_( "Upstream context key i.e., learning_package/library key" - ) + ), + null=True, + blank=True, ) downstream_usage_key = key_field() downstream_context_key = key_field() From 2cbe535a02de242f9869373e02f1d41d987141d2 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Fri, 17 Jan 2025 19:56:39 +0530 Subject: [PATCH 6/7] refactor: rename table and update upstream_context_key field --- .../apps/authoring/linking/admin.py | 8 +-- .../apps/authoring/linking/api.py | 54 ++++++++++--------- .../linking/migrations/0001_initial.py | 14 +++-- .../apps/authoring/linking/models.py | 32 +++++------ .../apps/authoring/linking/test_api.py | 20 +++---- 5 files changed, 65 insertions(+), 63 deletions(-) diff --git a/openedx_learning/apps/authoring/linking/admin.py b/openedx_learning/apps/authoring/linking/admin.py index 5e2f8f9a..872409ae 100644 --- a/openedx_learning/apps/authoring/linking/admin.py +++ b/openedx_learning/apps/authoring/linking/admin.py @@ -6,7 +6,7 @@ from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin -from .models import CourseLinksStatus, PublishableEntityLink +from .models import LearningContextLinksStatus, PublishableEntityLink @admin.register(PublishableEntityLink) @@ -45,10 +45,10 @@ class PublishableEntityLinkAdmin(ReadOnlyModelAdmin): ] -@admin.register(CourseLinksStatus) -class CourseLinksStatusAdmin(admin.ModelAdmin): +@admin.register(LearningContextLinksStatus) +class LearningContextLinksStatusAdmin(admin.ModelAdmin): """ - CourseLinksStatus admin. + LearningContextLinksStatus admin. """ fields = ( "context_key", diff --git a/openedx_learning/apps/authoring/linking/api.py b/openedx_learning/apps/authoring/linking/api.py index b8d79ad9..2a6d87ff 100644 --- a/openedx_learning/apps/authoring/linking/api.py +++ b/openedx_learning/apps/authoring/linking/api.py @@ -4,6 +4,7 @@ Please look at the models.py file for more information about the kinds of data are stored in this app. """ + from __future__ import annotations from datetime import datetime, timezone @@ -12,35 +13,37 @@ from django.db.models import QuerySet from ..components.models import Component -from .models import CourseLinksStatus, CourseLinksStatusChoices, PublishableEntityLink +from .models import LearningContextLinksStatus, LearningContextLinksStatusChoices, PublishableEntityLink __all__ = [ - "delete_entity_link", - "get_entity_links", - "get_or_create_course_link_status", - "update_or_create_entity_link", + 'delete_entity_link', + 'get_entity_links', + 'get_or_create_learning_context_link_status', + 'update_or_create_entity_link', ] -def get_or_create_course_link_status(context_key: str, created: datetime | None = None) -> CourseLinksStatus: +def get_or_create_learning_context_link_status( + context_key: str, created: datetime | None = None +) -> LearningContextLinksStatus: """ - Get or create course link status row from CourseLinksStatus table for given course key. + Get or create course link status row from LearningContextLinksStatus table for given course key. Args: - context_key: Course key + context_key: Learning context or Course key Returns: - CourseLinksStatus object + LearningContextLinksStatus object """ if not created: created = datetime.now(tz=timezone.utc) - status, _ = CourseLinksStatus.objects.get_or_create( + status, _ = LearningContextLinksStatus.objects.get_or_create( context_key=context_key, defaults={ - "status": CourseLinksStatusChoices.PENDING, - "created": created, - "updated": created, - } + 'status': LearningContextLinksStatusChoices.PENDING, + 'created': created, + 'updated': created, + }, ) return status @@ -56,6 +59,7 @@ def update_or_create_entity_link( upstream_block: Component | None, /, upstream_usage_key: str, + upstream_context_key: str, downstream_usage_key: str, downstream_context_key: str, downstream_context_title: str, @@ -69,18 +73,20 @@ def update_or_create_entity_link( if not created: created = datetime.now(tz=timezone.utc) new_values = { - "upstream_usage_key": upstream_usage_key, - "downstream_usage_key": downstream_usage_key, - "downstream_context_key": downstream_context_key, - "downstream_context_title": downstream_context_title, - "version_synced": version_synced, - "version_declined": version_declined, + 'upstream_usage_key': upstream_usage_key, + 'upstream_context_key': upstream_context_key, + 'downstream_usage_key': downstream_usage_key, + 'downstream_context_key': downstream_context_key, + 'downstream_context_title': downstream_context_title, + 'version_synced': version_synced, + 'version_declined': version_declined, } if upstream_block: - new_values.update({ - "upstream_block": upstream_block.publishable_entity, - "upstream_context_key": upstream_block.learning_package.key, - }) + new_values.update( + { + 'upstream_block': upstream_block.publishable_entity, + } + ) try: link = PublishableEntityLink.objects.get(downstream_usage_key=downstream_usage_key) has_changes = False diff --git a/openedx_learning/apps/authoring/linking/migrations/0001_initial.py b/openedx_learning/apps/authoring/linking/migrations/0001_initial.py index 013fd9e8..12d6bdc0 100644 --- a/openedx_learning/apps/authoring/linking/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/linking/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.17 on 2025-01-07 12:17 +# Generated by Django 4.2.15 on 2025-01-17 14:23 import uuid @@ -18,7 +18,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='CourseLinksStatus', + name='LearningContextLinksStatus', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ( @@ -38,7 +38,7 @@ class Migration(migrations.Migration): ('failed', 'Failed'), ('completed', 'Completed'), ], - help_text='Status of links in given course.', + help_text='Status of links in given learning context/course.', max_length=20, ), ), @@ -46,8 +46,8 @@ class Migration(migrations.Migration): ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), ], options={ - 'verbose_name': 'Course Links status', - 'verbose_name_plural': 'Course Links status', + 'verbose_name': 'Learning Context Links status', + 'verbose_name_plural': 'Learning Context Links status', }, ), migrations.CreateModel( @@ -69,11 +69,9 @@ class Migration(migrations.Migration): ( 'upstream_context_key', openedx_learning.lib.fields.MultiCollationCharField( - blank=True, db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, help_text='Upstream context key i.e., learning_package/library key', max_length=500, - null=True, ), ), ( @@ -117,7 +115,7 @@ class Migration(migrations.Migration): }, ), migrations.AddConstraint( - model_name='courselinksstatus', + model_name='learningcontextlinksstatus', constraint=models.UniqueConstraint(fields=('context_key',), name='oel_link_ent_status_ctx_key'), ), migrations.AddIndex( diff --git a/openedx_learning/apps/authoring/linking/models.py b/openedx_learning/apps/authoring/linking/models.py index 5156228d..60097d66 100644 --- a/openedx_learning/apps/authoring/linking/models.py +++ b/openedx_learning/apps/authoring/linking/models.py @@ -6,7 +6,6 @@ from openedx_learning.lib.fields import ( case_insensitive_char_field, - case_sensitive_char_field, immutable_uuid_field, key_field, manual_date_time_field, @@ -16,8 +15,8 @@ __all__ = [ "PublishableEntityLink", - "CourseLinksStatus", - "CourseLinksStatusChoices" + "LearningContextLinksStatus", + "LearningContextLinksStatusChoices" ] @@ -40,13 +39,10 @@ class PublishableEntityLink(models.Model): " and useful to track upstream library blocks that do not exist yet" ) ) - upstream_context_key = case_sensitive_char_field( - max_length=500, + upstream_context_key = key_field( help_text=_( "Upstream context key i.e., learning_package/library key" ), - null=True, - blank=True, ) downstream_usage_key = key_field() downstream_context_key = key_field() @@ -87,13 +83,13 @@ class Meta: name="oel_link_ent_idx_up_ctx_key" ), ] - verbose_name = "Publishable Entity Link" - verbose_name_plural = "Publishable Entity Links" + verbose_name = _("Publishable Entity Link") + verbose_name_plural = _("Publishable Entity Links") -class CourseLinksStatusChoices(models.TextChoices): +class LearningContextLinksStatusChoices(models.TextChoices): """ - Enumerates the states that a CourseLinksStatus can be in. + Enumerates the states that a LearningContextLinksStatus can be in. """ PENDING = "pending", _("Pending") PROCESSING = "processing", _("Processing") @@ -101,32 +97,32 @@ class CourseLinksStatusChoices(models.TextChoices): COMPLETED = "completed", _("Completed") -class CourseLinksStatus(models.Model): +class LearningContextLinksStatus(models.Model): """ This table stores current processing status of upstream-downstream links in PublishableEntityLink table for a - course. + course or a learning context. """ context_key = key_field( help_text=_("Linking status for downstream/course context key"), ) status = models.CharField( max_length=20, - choices=CourseLinksStatusChoices.choices, - help_text=_("Status of links in given course."), + choices=LearningContextLinksStatusChoices.choices, + help_text=_("Status of links in given learning context/course."), ) created = manual_date_time_field() updated = manual_date_time_field() class Meta: constraints = [ - # Single entry for a course + # Single entry for a learning context or course models.UniqueConstraint( fields=["context_key"], name="oel_link_ent_status_ctx_key", ) ] - verbose_name = "Course Links status" - verbose_name_plural = "Course Links status" + verbose_name = _("Learning Context Links status") + verbose_name_plural = _("Learning Context Links status") def __str__(self): return f"{self.status}|{self.context_key}" diff --git a/tests/openedx_learning/apps/authoring/linking/test_api.py b/tests/openedx_learning/apps/authoring/linking/test_api.py index 92a50dae..cddaf502 100644 --- a/tests/openedx_learning/apps/authoring/linking/test_api.py +++ b/tests/openedx_learning/apps/authoring/linking/test_api.py @@ -8,7 +8,7 @@ from openedx_learning.apps.authoring.components import api as components_api from openedx_learning.apps.authoring.components.models import Component, ComponentType from openedx_learning.apps.authoring.linking import api as linking_api -from openedx_learning.apps.authoring.linking.models import CourseLinksStatus, PublishableEntityLink +from openedx_learning.apps.authoring.linking.models import LearningContextLinksStatus, PublishableEntityLink from openedx_learning.apps.authoring.publishing import api as publishing_api from openedx_learning.apps.authoring.publishing.models import LearningPackage from openedx_learning.lib.test_utils import TestCase @@ -40,18 +40,18 @@ def setUpTestData(cls) -> None: created_by=None, ) - def test_get_or_create_course_link_status(self) -> None: + def test_get_or_create_learning_context_link_status(self) -> None: """ - Test get_or_create_course_link_status api. + Test get_or_create_learning_context_link_status api. """ context_key = "test_context_key" - assert not CourseLinksStatus.objects.filter(context_key=context_key).exists() - linking_api.get_or_create_course_link_status(context_key) - assert CourseLinksStatus.objects.filter(context_key=context_key).exists() - assert CourseLinksStatus.objects.filter(context_key=context_key).count() == 1 + assert not LearningContextLinksStatus.objects.filter(context_key=context_key).exists() + linking_api.get_or_create_learning_context_link_status(context_key) + assert LearningContextLinksStatus.objects.filter(context_key=context_key).exists() + assert LearningContextLinksStatus.objects.filter(context_key=context_key).count() == 1 # Should not create a new object - linking_api.get_or_create_course_link_status(context_key) - assert CourseLinksStatus.objects.filter(context_key=context_key).count() == 1 + linking_api.get_or_create_learning_context_link_status(context_key) + assert LearningContextLinksStatus.objects.filter(context_key=context_key).count() == 1 def test_update_or_create_entity_link(self) -> None: """ @@ -61,6 +61,7 @@ def test_update_or_create_entity_link(self) -> None: assert not PublishableEntityLink.objects.filter(downstream_usage_key=downstream_usage_key).exists() entity_args = { "upstream_usage_key": "test_upstream_usage_key", + "upstream_context_key": "test_upstream_context_key", "downstream_usage_key": downstream_usage_key, "downstream_context_key": "test_downstream_context_key", "downstream_context_title": "test_downstream_context_key", @@ -88,6 +89,7 @@ def test_delete_entity_link(self) -> None: downstream_usage_key = "test_downstream_usage_key" entity_args = { "upstream_usage_key": "test_upstream_usage_key", + "upstream_context_key": "test_upstream_context_key", "downstream_usage_key": downstream_usage_key, "downstream_context_key": "test_downstream_context_key", "downstream_context_title": "test_downstream_context_key", From b6010cb11015ded2cc6ef5df97c357023ab77a63 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 20 Jan 2025 10:15:47 +0530 Subject: [PATCH 7/7] feat: simple api to update learning context links status --- openedx_learning/apps/authoring/linking/api.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/openedx_learning/apps/authoring/linking/api.py b/openedx_learning/apps/authoring/linking/api.py index 2a6d87ff..2f3acfc8 100644 --- a/openedx_learning/apps/authoring/linking/api.py +++ b/openedx_learning/apps/authoring/linking/api.py @@ -20,6 +20,7 @@ 'get_entity_links', 'get_or_create_learning_context_link_status', 'update_or_create_entity_link', + 'update_learning_context_link_status', ] @@ -48,6 +49,22 @@ def get_or_create_learning_context_link_status( return status +def update_learning_context_link_status( + context_key: str, + status: LearningContextLinksStatusChoices, + updated: datetime | None = None +) -> None: + """ + Updates entity links processing status of given learning context. + """ + if not updated: + updated = datetime.now(tz=timezone.utc) + LearningContextLinksStatus.objects.filter(context_key=context_key).update( + status=status, + updated=updated, + ) + + def get_entity_links(filters: dict[str, Any]) -> QuerySet[PublishableEntityLink]: """ Get entity links based on passed filters.