diff --git a/openedx_learning/apps/authoring/publishing/admin.py b/openedx_learning/apps/authoring/publishing/admin.py index a2b5dbde1..5d2c52777 100644 --- a/openedx_learning/apps/authoring/publishing/admin.py +++ b/openedx_learning/apps/authoring/publishing/admin.py @@ -4,11 +4,19 @@ from __future__ import annotations from django.contrib import admin +from django.db.models import Count from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin, one_to_one_related_model_html -from .models import LearningPackage, PublishableEntity, Published, PublishLog, PublishLogRecord - +from .models import ( + DraftChange, + DraftChangeSet, + LearningPackage, + PublishableEntity, + Published, + PublishLog, + PublishLogRecord, +) @admin.register(LearningPackage) class LearningPackageAdmin(ReadOnlyModelAdmin): @@ -168,3 +176,67 @@ def published_at(self, published_obj): def message(self, published_obj): return published_obj.publish_log_record.publish_log.message + + +class DraftChangeTabularInline(admin.TabularInline): + model = DraftChange + + fields = ( + "entity", + "title", + "old_version_num", + "new_version_num", + ) + readonly_fields = fields + + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset.select_related("entity", "old_version", "new_version") \ + .order_by("entity__key") + + def old_version_num(self, draft_change: DraftChange): + if draft_change.old_version is None: + return "-" + return draft_change.old_version.version_num + + def new_version_num(self, draft_change: DraftChange): + if draft_change.new_version is None: + return "-" + return draft_change.new_version.version_num + + def title(self, draft_change: DraftChange): + """ + Get the title to display for the DraftChange + """ + if draft_change.new_version: + return draft_change.new_version.title + if draft_change.old_version: + return draft_change.old_version.title + return "" + + +@admin.register(DraftChangeSet) +class DraftChangeSetAdmin(ReadOnlyModelAdmin): + """ + Read-only admin to view Draft changes (via inline tables) + """ + inlines = [DraftChangeTabularInline] + fields = ( + "uuid", + "learning_package", + "change_set_type", + "num_changes", + "changed_at", + "changed_by", + ) + readonly_fields = fields + list_display = fields + list_filter = ["learning_package", "change_set_type"] + + def num_changes(self, draft_change_set): + return draft_change_set.num_changes + + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset.select_related("learning_package", "changed_by") \ + .annotate(num_changes=Count("changes")) diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index 5c82c2f1d..04e1801eb 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -18,6 +18,8 @@ Container, ContainerVersion, Draft, + DraftChange, + DraftChangeSet, EntityList, EntityListRow, LearningPackage, @@ -207,10 +209,8 @@ def create_publishable_entity_version( created=created, created_by_id=created_by, ) - Draft.objects.update_or_create( - entity_id=entity_id, - defaults={"version": version}, - ) + set_draft_version(entity_id, version.id, set_at=created, set_by=created_by) + return version @@ -437,6 +437,8 @@ def set_draft_version( publishable_entity_id: int, publishable_entity_version_pk: int | None, /, + set_at: datetime | None = None, + set_by: int | None = None, # User.id ) -> None: """ Modify the Draft of a PublishableEntity to be a PublishableEntityVersion. @@ -447,12 +449,37 @@ def set_draft_version( from Studio's editing point of view (see ``soft_delete_draft`` for more details). """ - draft = Draft.objects.get(entity_id=publishable_entity_id) - draft.version_id = publishable_entity_version_pk - draft.save() + if set_at is None: + set_at = datetime.now(tz=timezone.utc) + + with atomic(): + draft, created = Draft.objects.select_related("entity").get_or_create(entity_id=publishable_entity_id) + old_version_id = draft.version_id + if created: + change_set_type = DraftChangeSet.ChangeSetType.CREATE + elif publishable_entity_version_pk is None: + change_set_type = DraftChangeSet.ChangeSetType.DELETE + else: + change_set_type = DraftChangeSet.ChangeSetType.EDIT + + draft.version_id = publishable_entity_version_pk + draft.save() + + change_set = DraftChangeSet.objects.create( + learning_package_id=draft.entity.learning_package_id, + changed_at=set_at, + changed_by_id=set_by, + change_set_type=change_set_type, + ) + DraftChange.objects.create( + change_set_id=change_set.id, + entity_id=publishable_entity_id, + old_version_id=old_version_id, + new_version_id=publishable_entity_version_pk, + ) -def soft_delete_draft(publishable_entity_id: int, /) -> None: +def soft_delete_draft(publishable_entity_id: int, /, deleted_by: int | None = None) -> None: """ Sets the Draft version to None. @@ -462,10 +489,15 @@ def soft_delete_draft(publishable_entity_id: int, /) -> None: of pointing the Draft back to the most recent ``PublishableEntityVersion`` for a given ``PublishableEntity``. """ - return set_draft_version(publishable_entity_id, None) + return set_draft_version(publishable_entity_id, None, set_by=deleted_by) -def reset_drafts_to_published(learning_package_id: int, /) -> None: +def reset_drafts_to_published( + learning_package_id: int, + /, + reset_at: datetime | None = None, + reset_by: int | None = None, # User.id +) -> None: """ Reset all Drafts to point to the most recently Published versions. @@ -493,22 +525,53 @@ def reset_drafts_to_published(learning_package_id: int, /) -> None: Also, there is no current immutable record for when a reset happens. It's not like a publish that leaves an entry in the ``PublishLog``. """ + if reset_at is None: + reset_at = datetime.now(tz=timezone.utc) + # These are all the drafts that are different from the published versions. draft_qset = Draft.objects \ .select_related("entity__published") \ .filter(entity__learning_package_id=learning_package_id) \ - .exclude(entity__published__version_id=F("version_id")) + .exclude(entity__published__version_id=F("version_id")) \ + .exclude( + # NULL != NULL in SQL, so we want to exclude entries + # where both the published version and draft version + # are None. This edge case happens when we create + # something and then delete it without publishing, and + # then reset Drafts to their published state. + Q(entity__published__version__isnull=True) & + Q(version__isnull=True) + ) + # If there's nothing to reset because there are no changes from the + # published version, just return early rather than making an empty + # DraftChangeSet. + if not draft_qset: + return # Note: We can't do an .update with a F() on a joined field in the ORM, so # we have to loop through the drafts individually to reset them. We can # rework this into a bulk update or custom SQL if it becomes a performance # issue. with atomic(): + change_set = DraftChangeSet.objects.create( + learning_package_id=learning_package_id, + changed_at=reset_at, + changed_by_id=reset_by, + change_set_type=DraftChangeSet.ChangeSetType.RESET_TO_PUBLISHED, + ) for draft in draft_qset: if hasattr(draft.entity, 'published'): - draft.version_id = draft.entity.published.version_id + published_version_id = draft.entity.published.version_id else: - draft.version = None + published_version_id = None + + DraftChange.objects.create( + change_set_id=change_set.id, + entity_id=draft.entity_id, + old_version_id=draft.version_id, + new_version_id=published_version_id, + ) + draft.version_id = published_version_id draft.save() diff --git a/openedx_learning/apps/authoring/publishing/migrations/0004_draftchangeset_and_more.py b/openedx_learning/apps/authoring/publishing/migrations/0004_draftchangeset_and_more.py new file mode 100644 index 000000000..ac6133caa --- /dev/null +++ b/openedx_learning/apps/authoring/publishing/migrations/0004_draftchangeset_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.18 on 2025-03-10 22:32 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import openedx_learning.lib.validators +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oel_publishing', '0003_containers'), + ] + + operations = [ + migrations.CreateModel( + name='DraftChangeSet', + 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')), + ('change_set_type', models.SmallIntegerField(choices=[(0, 'Migration Initialization'), (1, 'Create'), (2, 'Edit'), (3, 'Delete'), (4, 'Reset to Published')], default=2)), + ('changed_at', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('changed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('learning_package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.learningpackage')), + ], + options={ + 'verbose_name': 'Draft Change Set', + 'verbose_name_plural': 'Draft Change Sets', + }, + ), + migrations.CreateModel( + name='DraftChange', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('change_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changes', to='oel_publishing.draftchangeset')), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='oel_publishing.publishableentity')), + ('new_version', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, to='oel_publishing.publishableentityversion')), + ('old_version', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='oel_publishing.publishableentityversion')), + ], + options={ + 'verbose_name': 'Draft Change', + 'verbose_name_plural': 'Draft Changes', + 'indexes': [models.Index(fields=['entity', '-change_set'], name='oel_dc_idx_entity_rchangeset')], + }, + ), + migrations.AddConstraint( + model_name='draftchange', + constraint=models.UniqueConstraint(fields=('change_set', 'entity'), name='oel_dc_uniq_changeset_entity'), + ), + ] diff --git a/openedx_learning/apps/authoring/publishing/migrations/0005_bootstrap_draftchangeset.py b/openedx_learning/apps/authoring/publishing/migrations/0005_bootstrap_draftchangeset.py new file mode 100644 index 000000000..de8addc5c --- /dev/null +++ b/openedx_learning/apps/authoring/publishing/migrations/0005_bootstrap_draftchangeset.py @@ -0,0 +1,106 @@ +""" +Bootstrap DraftChangeSets + +DraftChange and DraftChangeSet are being introduced after Drafts, so we're going +to retroactively make DraftChanges for all the changes that were in our Learning +Packages. + +This migration will try to reconstruct create, edit, reset-to-published, and +delete operations, but it won't be fully accurate because we only have the +create dates of the versions and the current state of active Drafts to go on. +This means we won't accurately capture when things were deleted and then reset, +or when reset and then later edited. Addressing this gap is a big part of why +we're creating DraftChangeSets in the first place. +""" +# Generated by Django 4.2.18 on 2025-03-13 10:29 +import logging +from datetime import datetime, timezone + +from django.db import migrations + +logger = logging.getLogger(__name__) + + +def bootstrap_draft_change_sets(apps, schema_editor): + """ + Create a fake DraftChangeSet that encompasses the state of current Drafts. + """ + # We're copying the ChangeSetTypes here from the model, since we don't want + # to accidentally import a later state of the model. + CREATE = 1 + EDIT = 2 + DELETE = 3 + RESET_TO_PUBLISHED = 4 + + LearningPackage = apps.get_model("oel_publishing", "LearningPackage") + PublishableEntityVersion = apps.get_model("oel_publishing", "PublishableEntityVersion") + + Draft = apps.get_model("oel_publishing", "Draft") + DraftChange = apps.get_model("oel_publishing", "DraftChange") + DraftChangeSet = apps.get_model("oel_publishing", "DraftChangeSet") + now = datetime.now(tz=timezone.utc) + + for learning_package in LearningPackage.objects.all().order_by("key"): + logger.info(f"Creating bootstrap DraftChangeSets for {learning_package.key}") + pub_ent_versions = PublishableEntityVersion.objects.filter( + entity__learning_package=learning_package + ).select_related("entity") + + # First cycle though all the simple create/edit operations... + last_version_seen = {} # PublishableEntity.id -> PublishableEntityVersion.id + for pub_ent_version in pub_ent_versions.order_by("pk"): + change_set_type = CREATE if pub_ent_version.version_num == 1 else EDIT + + change_set = DraftChangeSet.objects.create( + learning_package=learning_package, + changed_at=pub_ent_version.created, + changed_by=pub_ent_version.created_by, + change_set_type=change_set_type, + ) + DraftChange.objects.create( + change_set=change_set, + entity=pub_ent_version.entity, + old_version_id=last_version_seen.get(pub_ent_version.entity.id), + new_version_id=pub_ent_version.id, + ) + last_version_seen[pub_ent_version.entity.id] = pub_ent_version.id + + # Now that we've created change sets for create/edit operations, we look + # at the latest state of the Draft model in order to determine whether + # we need to apply deletes or resets. + for draft in Draft.objects.filter(entity__learning_package=learning_package).order_by("entity_id"): + last_version_id = last_version_seen.get(draft.entity_id) + if draft.version_id == last_version_id: + continue + change_set_type = DELETE if draft.version_id is None else RESET_TO_PUBLISHED + + # We don't really know who did this or when, so we use None and now. + change_set = DraftChangeSet.objects.create( + learning_package=learning_package, + changed_at=now, + changed_by=None, + change_set_type=change_set_type, + ) + DraftChange.objects.create( + change_set=change_set, + entity_id=draft.entity_id, + old_version_id=last_version_id, + new_version_id=draft.version_id, + ) + + +def delete_draft_change_sets(apps, schema_editor): + logger.info(f"Deleting all DraftChangeSets (reversre migration)") + DraftChangeSet = apps.get_model("oel_publishing", "DraftChangeSet") + DraftChangeSet.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('oel_publishing', '0004_draftchangeset_and_more'), + ] + + operations = [ + migrations.RunPython(bootstrap_draft_change_sets, reverse_code=delete_draft_change_sets) + ] diff --git a/openedx_learning/apps/authoring/publishing/models/__init__.py b/openedx_learning/apps/authoring/publishing/models/__init__.py index 27329076d..a66a77e77 100644 --- a/openedx_learning/apps/authoring/publishing/models/__init__.py +++ b/openedx_learning/apps/authoring/publishing/models/__init__.py @@ -14,7 +14,7 @@ """ from .container import Container, ContainerVersion -from .draft_published import Draft, Published +from .draft_published import Draft, DraftChange, DraftChangeSet, Published from .entity_list import EntityList, EntityListRow from .learning_package import LearningPackage from .publish_log import PublishLog, PublishLogRecord diff --git a/openedx_learning/apps/authoring/publishing/models/draft_published.py b/openedx_learning/apps/authoring/publishing/models/draft_published.py index c945d807d..335451460 100644 --- a/openedx_learning/apps/authoring/publishing/models/draft_published.py +++ b/openedx_learning/apps/authoring/publishing/models/draft_published.py @@ -1,8 +1,13 @@ """ Draft and Published models """ +from django.conf import settings from django.db import models +from django.utils.translation import gettext_lazy as _ +from openedx_learning.lib.fields import immutable_uuid_field, manual_date_time_field + +from .learning_package import LearningPackage from .publish_log import PublishLogRecord from .publishable_entity import PublishableEntity, PublishableEntityVersion @@ -93,3 +98,85 @@ class Published(models.Model): class Meta: verbose_name = "Published Entity" verbose_name_plural = "Published Entities" + + +class DraftChangeSet(models.Model): + """ + There is one row in this table for every time Drafts are created/modified. + + Most of the time we'll only be changing one Draft at a time, and this will + be 1:1 with DraftChange. But there are some operations that affect many + Drafts at once, such as discarding changes (i.e. reset to the published + version) or doing an import. + """ + class ChangeSetType(models.IntegerChoices): + CREATE = 1, _("Create"), + EDIT = 2, _("Edit"), + DELETE = 3, _("Delete"), + RESET_TO_PUBLISHED = 4, _("Reset to Published") + + uuid = immutable_uuid_field() + change_set_type = models.SmallIntegerField( + choices=ChangeSetType.choices, + default=ChangeSetType.EDIT, + ) + learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) + changed_at = manual_date_time_field() + changed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + class Meta: + verbose_name = "Draft Change Set" + verbose_name_plural = "Draft Change Sets" + + +class DraftChange(models.Model): + """ + A single change in the Draft version of a Publishable Entity + """ + change_set = models.ForeignKey( + DraftChangeSet, + on_delete=models.CASCADE, + related_name="changes", + ) + entity = models.ForeignKey(PublishableEntity, on_delete=models.RESTRICT) + old_version = models.ForeignKey( + PublishableEntityVersion, + on_delete=models.RESTRICT, + null=True, + blank=True, + related_name="+", + ) + new_version = models.ForeignKey( + PublishableEntityVersion, on_delete=models.RESTRICT, null=True, blank=True + ) + + class Meta: + constraints = [ + # A PublishableEntity can have only one DraftLogRecord per DraftLog. + # You can't simultaneously change the same thing in two different + # ways, e.g. set the Draft to version 1 and version 2 at the same + # time; or delete a Draft and set it to version 2 at the same time. + models.UniqueConstraint( + fields=[ + "change_set", + "entity", + ], + name="oel_dc_uniq_changeset_entity", + ) + ] + indexes = [ + # Entity (reverse) DraftLog Index: + # * Find the history of draft changes for a given entity, starting + # with the most recent (since IDs are ascending ints). + models.Index( + fields=["entity", "-change_set"], + name="oel_dc_idx_entity_rchangeset", + ), + ] + verbose_name = "Draft Change" + verbose_name_plural = "Draft Changes"