diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index 1da667af..5ec04854 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -12,6 +12,7 @@ from ..apps.authoring.collections.api import * from ..apps.authoring.components.api import * from ..apps.authoring.contents.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 diff --git a/openedx_learning/api/authoring_models.py b/openedx_learning/api/authoring_models.py index fb0ab669..f3428636 100644 --- a/openedx_learning/api/authoring_models.py +++ b/openedx_learning/api/authoring_models.py @@ -10,5 +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 * 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..872409ae --- /dev/null +++ b/openedx_learning/apps/authoring/linking/admin.py @@ -0,0 +1,65 @@ +""" +Django admin for linking models +""" + +from django.contrib import admin + +from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin + +from .models import LearningContextLinksStatus, PublishableEntityLink + + +@admin.register(PublishableEntityLink) +class PublishableEntityLinkAdmin(ReadOnlyModelAdmin): + """ + PublishableEntityLink admin. + """ + 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(LearningContextLinksStatus) +class LearningContextLinksStatusAdmin(admin.ModelAdmin): + """ + LearningContextLinksStatus admin. + """ + fields = ( + "context_key", + "status", + "created", + "updated", + ) + readonly_fields = ("created", "updated") + list_display = ( + "context_key", + "status", + "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..2a6d87ff --- /dev/null +++ b/openedx_learning/apps/authoring/linking/api.py @@ -0,0 +1,112 @@ +""" +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 LearningContextLinksStatus, LearningContextLinksStatusChoices, PublishableEntityLink + +__all__ = [ + 'delete_entity_link', + 'get_entity_links', + 'get_or_create_learning_context_link_status', + 'update_or_create_entity_link', +] + + +def get_or_create_learning_context_link_status( + context_key: str, created: datetime | None = None +) -> LearningContextLinksStatus: + """ + Get or create course link status row from LearningContextLinksStatus table for given course key. + + Args: + context_key: Learning context or Course key + + Returns: + LearningContextLinksStatus object + """ + if not created: + created = datetime.now(tz=timezone.utc) + status, _ = LearningContextLinksStatus.objects.get_or_create( + context_key=context_key, + defaults={ + 'status': LearningContextLinksStatusChoices.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 | None, + /, + upstream_usage_key: str, + upstream_context_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_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, + } + ) + 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..12d6bdc0 --- /dev/null +++ b/openedx_learning/apps/authoring/linking/migrations/0001_initial.py @@ -0,0 +1,133 @@ +# Generated by Django 4.2.15 on 2025-01-17 14:23 + +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='LearningContextLinksStatus', + 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 learning context/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': 'Learning Context Links status', + 'verbose_name_plural': 'Learning Context 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='learningcontextlinksstatus', + 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..60097d66 --- /dev/null +++ b/openedx_learning/apps/authoring/linking/models.py @@ -0,0 +1,128 @@ +""" +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", + "LearningContextLinksStatus", + "LearningContextLinksStatusChoices" +] + + +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 LearningContextLinksStatusChoices(models.TextChoices): + """ + Enumerates the states that a LearningContextLinksStatus can be in. + """ + PENDING = "pending", _("Pending") + PROCESSING = "processing", _("Processing") + FAILED = "failed", _("Failed") + COMPLETED = "completed", _("Completed") + + +class LearningContextLinksStatus(models.Model): + """ + This table stores current processing status of upstream-downstream links in PublishableEntityLink table for a + 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=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 learning context or course + models.UniqueConstraint( + fields=["context_key"], + name="oel_link_ent_status_ctx_key", + ) + ] + 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/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", ] 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..cddaf502 --- /dev/null +++ b/tests/openedx_learning/apps/authoring/linking/test_api.py @@ -0,0 +1,102 @@ +""" +Tests of the Linking app's python API +""" +from __future__ import annotations + +from datetime import datetime, timezone + +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 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 + + +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_learning_context_link_status(self) -> None: + """ + Test get_or_create_learning_context_link_status api. + """ + context_key = "test_context_key" + 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_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: + """ + 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", + "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", + "version_synced": 1, + } + # Should create new link + 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) # 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} # type: ignore[arg-type] + ) + 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", + "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", + "version_synced": 1, + } + # Should create new 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() + linking_api.delete_entity_link(downstream_usage_key) + assert not PublishableEntityLink.objects.filter(downstream_usage_key=downstream_usage_key).exists()