Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions openedx_learning/apps/authoring/publishing/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"))
89 changes: 76 additions & 13 deletions openedx_learning/apps/authoring/publishing/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
Container,
ContainerVersion,
Draft,
DraftChange,
DraftChangeSet,
EntityList,
EntityListRow,
LearningPackage,
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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.

Expand Down Expand Up @@ -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()


Expand Down
Original file line number Diff line number Diff line change
@@ -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'),
),
]
Original file line number Diff line number Diff line change
@@ -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)
]
Loading
Loading