Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Collections of PublishableEntities #131

Closed
wants to merge 9 commits into from
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
Empty file.
65 changes: 65 additions & 0 deletions openedx_learning/core/collections/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
Django admin for Collections.

This is extremely bare-bones at the moment, and basically gives you just enough
information to let you know whether it's working or not.
"""
from django.contrib import admin

from openedx_learning.lib.admin_utils import ReadOnlyModelAdmin

from .models import (
AddEntity,
Collection,
ChangeSet,
UpdateEntities,
RemoveEntity
)


class CollectionChangeSetTabularInline(admin.TabularInline):
model = ChangeSet
fields = ["version_num", "created"]
readonly_fields = ["version_num", "created"]


class PublishableEntityInline(admin.TabularInline):
model = Collection.entities.through


@admin.register(Collection)
class CollectionAdmin(ReadOnlyModelAdmin):
"""
Read-only admin for LearningPackage model
"""
fields = ["learning_package", "key", "title", "uuid", "created", "created_by"]
readonly_fields = ["learning_package", "key", "title", "uuid", "created", "created_by"]
list_display = ["learning_package", "key", "title", "uuid", "created", "created_by"]
search_fields = ["key", "title", "uuid"]
list_filter = ["learning_package"]

inlines = [
CollectionChangeSetTabularInline,
PublishableEntityInline,
]


class AddToCollectionTabularInline(admin.TabularInline):
model = AddEntity


class RemoveFromCollectionTabularInline(admin.TabularInline):
model = RemoveEntity


class PublishEntityTabularInline(admin.TabularInline):
model = UpdateEntities


@admin.register(ChangeSet)
class CollectionChangeSetAdmin(ReadOnlyModelAdmin):
inlines = [
AddToCollectionTabularInline,
RemoveFromCollectionTabularInline,
PublishEntityTabularInline,
]
166 changes: 166 additions & 0 deletions openedx_learning/core/collections/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""
API to manipulate Collections.
"""
from __future__ import annotations

from datetime import datetime, timezone

from django.db.models import QuerySet
from django.db.transaction import atomic

from ..publishing.models import PublishableEntity
from .models import (
Collection, CollectionPublishableEntity, ChangeSet,
AddEntity, UpdateEntities,
)


def create_collection(
learning_package_id: int,
key: str,
title: str,
pub_entities_qset: QuerySet = PublishableEntity.objects.none, # default to empty qset
created: datetime | None = None,
created_by_id: int | None = None,
) -> Collection:
"""
Create a Collection and populate with a QuerySet of PublishableEntity.
"""
if not created:
created = datetime.now(tz=timezone.utc)

with atomic():
collection = Collection(
learning_package_id=learning_package_id,
key=key,
title=title,
created=created,
created_by_id=created_by_id,
)
collection.full_clean()
collection.save()

# add_to_collection is what creates our initial CollectionChangeSet, so
# we always call it, even if we're just creating an empty Collection.
add_to_collection(collection.id, pub_entities_qset, created=created)

return collection

def get_collection(collection_id: int) -> Collection:
"""
Get a Collection by ID.
"""
return Collection.objects.get(id=collection_id)

def get_collections_matching_entities(entity_ids_qs: QuerySet) -> QuerySet:
"""
Get a QuerySet of Collections that have any of these PublishableEntities.
"""
return Collection.objects.filter(publishable_entities__in=entity_ids_qs).distinct()

def get_last_change_set(collection_id: int) -> ChangeSet | None:
"""
Get the most recent ChangeSet for this Collection.

This may return None if there is no matching ChangeSet (i.e. this is a newly
created Collection).
"""
return ChangeSet.objects \
.filter(collection_id=collection_id) \
.order_by('-version_num') \
.first()

def get_next_version_num(collection_id: int) -> int:
last_change_set = get_last_change_set(collection_id=collection_id)
return last_change_set.version_num + 1 if last_change_set else 1


def update_collection_with_publish_log(collection_id: int, publish_log) -> ChangeSet:
change_set = create_next_change_set(collection_id, publish_log.published_at)
UpdateEntities.objects.create(change_set=change_set, publish_log=publish_log)
return change_set


def create_next_change_set(collection_id: int, created: datetime | None) -> ChangeSet:
return ChangeSet.objects.create(
collection_id=collection_id,
version_num=get_next_version_num(collection_id),
created=created,
)

def create_update_entities():
pass



def add_to_collection(
collection_id: int,
pub_entities_qset: QuerySet,
created: datetime | None = None
)-> ChangeSet:
"""
Add a QuerySet of PublishableEntities to a Collection.
"""
next_version_num = get_next_version_num(collection_id)
with atomic():
change_set = ChangeSet.objects.create(
collection_id=collection_id,
version_num=next_version_num,
created=created,
)

# Add the joins so we can efficiently query the published versions.
qset = pub_entities_qset.select_related('published', 'published__version')

# We're going to build our relationship models into big lists and then
# use bulk_create on them in order to reduce the number of queries
# required for this as the size of Collections grow. This should be
# reasonable for up to hundreds of PublishableEntities, but we may have
# to look into more complex chunking and async processing if we go
# beyond that.
change_set_adds = []
collection_pub_entities = []
for pub_ent in qset.all():
if hasattr(pub_ent, 'published'):
published_version = pub_ent.published.version
else:
published_version = None

# These will be associated with the ChangeSet for history tracking.
change_set_adds.append(
AddEntity(
change_set=change_set,
entity=pub_ent,
published_version=published_version,
)
)

# These are the direct Collection <-> PublishableEntity M2M mappings
collection_pub_entities.append(
CollectionPublishableEntity(
collection_id=collection_id,
entity_id=pub_ent.id,
)
)

AddEntity.objects.bulk_create(change_set_adds)
CollectionPublishableEntity.objects.bulk_create(collection_pub_entities)

return change_set


def remove_from_collection(
collection_id: int,
pub_entities_qset: QuerySet,
created: datetime | None = None
) -> ChangeSet:
next_version_num = get_next_version_num(collection_id)

with atomic():
change_set = ChangeSet.objects.create(
collection_id=collection_id,
version_num=next_version_num,
created=created,
)

return change_set
27 changes: 27 additions & 0 deletions openedx_learning/core/collections/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""
Django metadata for the Collections Django application.
"""
from django.apps import AppConfig


class CollectionsConfig(AppConfig):
"""
Configuration for the Collections Django application.
"""

name = "openedx_learning.core.collections"
verbose_name = "Learning Core: Collections"
default_auto_field = "django.db.models.BigAutoField"
label = "oel_collections"

def ready(self):
"""
Register the ComponentCollection, ComponentCollectionVersion relation.
"""
from ..publishing.signals import PUBLISHED_PRE_COMMIT
from . import handlers

PUBLISHED_PRE_COMMIT.connect(
handlers.update_collections_from_publish,
dispatch_uid="oel__collections__update_collections_from_publish",
)
29 changes: 29 additions & 0 deletions openedx_learning/core/collections/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""
Signal handlers for Collections.

This is to catch updates when things are published. The reason that we use
signals to do this kind of updating is because the ``publishing`` app exists at
a lower layer than the ``collections`` app, i.e. ``publishing`` should not know
that ``collections`` exists. If ``publishing`` updated Collections directly, it
would introduce a circular dependency.
"""
from django.db.transaction import atomic

from .api import (
get_collections_matching_entities,
update_collection_with_publish_log,
)


def update_collections_from_publish(sender, publish_log=None, **kwargs):
"""
Update all Collections affected by the publish described by publish_log.
"""
# Find all Collections that had at least one PublishableEntity that was
# published in this PublishLog.
affected_collections = get_collections_matching_entities(
publish_log.records.values('entity__id')
)
with atomic():
for collection in affected_collections:
update_collection_with_publish_log(collection.id, publish_log)
Loading
Loading