Skip to content

Commit 9db159a

Browse files
authored
Merge pull request #4632 from rtibbles/this_is_unpublishable
Update backend change event generation to mark some changes as unpublishable
2 parents a9bd3b9 + 8a9293e commit 9db159a

File tree

10 files changed

+266
-15
lines changed

10 files changed

+266
-15
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Generated by Django 3.2.19 on 2024-08-09 18:28
2+
from django.db import migrations
3+
from django.db import models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('contentcuration', '0148_flagfeedbackevent_recommendationsevent_recommendationsinteractionevent'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='change',
15+
name='unpublishable',
16+
field=models.BooleanField(blank=True, null=True),
17+
),
18+
# Add default to False in a separate migration operation
19+
# to avoid expensive backfilling of the new column for existing rows
20+
migrations.AlterField(
21+
model_name='change',
22+
name='unpublishable',
23+
field=models.BooleanField(blank=True, default=False, null=True),
24+
),
25+
]

contentcuration/contentcuration/models.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777
from contentcuration.utils.parser import load_json_string
7878
from contentcuration.viewsets.sync.constants import ALL_CHANGES
7979
from contentcuration.viewsets.sync.constants import ALL_TABLES
80+
from contentcuration.viewsets.sync.constants import PUBLISHABLE_CHANGE_TABLES
81+
from contentcuration.viewsets.sync.constants import PUBLISHED
8082

8183

8284
EDIT_ACCESS = "edit"
@@ -2556,14 +2558,36 @@ class Change(models.Model):
25562558
kwargs = JSONField(encoder=JSONEncoder)
25572559
applied = models.BooleanField(default=False)
25582560
errored = models.BooleanField(default=False)
2561+
# Add an additional flag for change events that are only intended
2562+
# to transmit a message to the client, and not to actually apply any publishable changes.
2563+
# Make it nullable, so that we don't have to back fill historic change objects, and we just
2564+
# exclude true values when we are looking for publishable changes.
2565+
# This deliberately uses 'unpublishable' so that we can easily filter out by 'not true',
2566+
# and also that if we are ever interacting with it in Python code, both null and False values
2567+
# will be falsy.
2568+
unpublishable = models.BooleanField(null=True, blank=True, default=False)
25592569

25602570
@classmethod
2561-
def _create_from_change(cls, created_by_id=None, channel_id=None, user_id=None, session_key=None, applied=False, table=None, rev=None, **data):
2571+
def _create_from_change(
2572+
cls,
2573+
created_by_id=None,
2574+
channel_id=None,
2575+
user_id=None,
2576+
session_key=None,
2577+
applied=False,
2578+
table=None,
2579+
rev=None,
2580+
unpublishable=False,
2581+
**data
2582+
):
25622583
change_type = data.pop("type")
25632584
if table is None or table not in ALL_TABLES:
25642585
raise TypeError("table is a required argument for creating changes and must be a valid table name")
25652586
if change_type is None or change_type not in ALL_CHANGES:
25662587
raise TypeError("change_type is a required argument for creating changes and must be a valid change type integer")
2588+
# Don't let someone mark a change as unpublishable if it's not in the list of tables that make changes that we can publish
2589+
# also, by definition, publishing is not a publishable change - this probably doesn't matter, but making sense is nice.
2590+
unpublishable = unpublishable or table not in PUBLISHABLE_CHANGE_TABLES or change_type == PUBLISHED
25672591
return cls(
25682592
session_id=session_key,
25692593
created_by_id=created_by_id,
@@ -2573,21 +2597,30 @@ def _create_from_change(cls, created_by_id=None, channel_id=None, user_id=None,
25732597
table=table,
25742598
change_type=change_type,
25752599
kwargs=data,
2576-
applied=applied
2600+
applied=applied,
2601+
unpublishable=unpublishable,
25772602
)
25782603

25792604
@classmethod
2580-
def create_changes(cls, changes, created_by_id=None, session_key=None, applied=False):
2605+
def create_changes(cls, changes, created_by_id=None, session_key=None, applied=False, unpublishable=False):
25812606
change_models = []
25822607
for change in changes:
2583-
change_models.append(cls._create_from_change(created_by_id=created_by_id, session_key=session_key, applied=applied, **change))
2608+
change_models.append(
2609+
cls._create_from_change(
2610+
created_by_id=created_by_id,
2611+
session_key=session_key,
2612+
applied=applied,
2613+
unpublishable=unpublishable,
2614+
**change
2615+
)
2616+
)
25842617

25852618
cls.objects.bulk_create(change_models)
25862619
return change_models
25872620

25882621
@classmethod
2589-
def create_change(cls, change, created_by_id=None, session_key=None, applied=False):
2590-
obj = cls._create_from_change(created_by_id=created_by_id, session_key=session_key, applied=applied, **change)
2622+
def create_change(cls, change, created_by_id=None, session_key=None, applied=False, unpublishable=False):
2623+
obj = cls._create_from_change(created_by_id=created_by_id, session_key=session_key, applied=applied, unpublishable=unpublishable, **change)
25912624
obj.save()
25922625
return obj
25932626

contentcuration/contentcuration/tests/viewsets/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from contentcuration.viewsets.sync.utils import generate_create_event as base_generate_create_event
1313
from contentcuration.viewsets.sync.utils import generate_delete_event as base_generate_delete_event
1414
from contentcuration.viewsets.sync.utils import generate_deploy_event as base_generate_deploy_event
15+
from contentcuration.viewsets.sync.utils import generate_publish_event as base_generate_publish_event
1516
from contentcuration.viewsets.sync.utils import generate_update_event as base_generate_update_event
1617
from contentcuration.viewsets.sync.utils import generate_update_descendants_event as base_generate_update_descendants_event
1718

@@ -60,6 +61,12 @@ def generate_update_descendants_event(*args, **kwargs):
6061
event["rev"] = random.randint(1, 10000000)
6162
return event
6263

64+
def generate_publish_channel_event(channel_id):
65+
event = base_generate_publish_event(channel_id)
66+
event["rev"] = random.randint(1, 10000000)
67+
return event
68+
69+
6370
class SyncTestMixin(object):
6471
celery_task_always_eager = None
6572

contentcuration/contentcuration/tests/viewsets/test_channel.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import uuid
44

55
import mock
6+
from django.db.models import Exists
7+
from django.db.models import OuterRef
68
from django.urls import reverse
79
from le_utils.constants import content_kinds
810

@@ -14,9 +16,11 @@
1416
from contentcuration.tests.viewsets.base import generate_create_event
1517
from contentcuration.tests.viewsets.base import generate_delete_event
1618
from contentcuration.tests.viewsets.base import generate_deploy_channel_event
19+
from contentcuration.tests.viewsets.base import generate_publish_channel_event
1720
from contentcuration.tests.viewsets.base import generate_sync_channel_event
1821
from contentcuration.tests.viewsets.base import generate_update_event
1922
from contentcuration.tests.viewsets.base import SyncTestMixin
23+
from contentcuration.viewsets.channel import _unpublished_changes_query
2024
from contentcuration.viewsets.sync.constants import CHANNEL
2125

2226

@@ -374,6 +378,19 @@ def test_deploy_with_staging_tree_None(self):
374378
self.assertNotEqual(modified_channel.main_tree, channel.staging_tree)
375379
self.assertNotEqual(modified_channel.previous_tree, channel.main_tree)
376380

381+
def test_publish_does_not_make_publishable(self):
382+
user = testdata.user()
383+
channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata)
384+
channel.editors.add(user)
385+
386+
self.sync_changes(
387+
[
388+
generate_publish_channel_event(channel.id)
389+
]
390+
)
391+
392+
self.assertEqual(_unpublished_changes_query(channel).count(), 0)
393+
377394

378395
class CRUDTestCase(StudioAPITestCase):
379396
@property
@@ -466,3 +483,134 @@ def test_admin_restore_channel(self):
466483
channel = models.Channel.objects.get(id=channel.id)
467484
self.assertFalse(channel.deleted)
468485
self.assertEqual(1, channel.history.filter(actor=user, action=channel_history.RECOVERY).count())
486+
487+
488+
class UnpublishedChangesQueryTestCase(StudioAPITestCase):
489+
def test_unpublished_changes_query_with_channel_object(self):
490+
channel = testdata.channel()
491+
user = testdata.user()
492+
models.Change.create_change(generate_update_event(channel.id, CHANNEL, {"name": "new name"}, channel_id=channel.id), created_by_id=user.id)
493+
models.Change.create_change(generate_publish_channel_event(channel.id), created_by_id=user.id)
494+
models.Change.create_change(generate_update_event(channel.id, CHANNEL, {"name": "new name 2"}, channel_id=channel.id), created_by_id=user.id)
495+
496+
queryset = _unpublished_changes_query(channel)
497+
self.assertEqual(queryset.count(), 1)
498+
self.assertEqual(queryset[0].kwargs["mods"]["name"], "new name 2")
499+
500+
def test_unpublished_changes_query_with_channel_object_none_since_publish(self):
501+
channel = testdata.channel()
502+
user = testdata.user()
503+
models.Change.create_change(generate_update_event(channel.id, CHANNEL, {"name": "new name"}, channel_id=channel.id), created_by_id=user.id)
504+
models.Change.create_change(generate_update_event(channel.id, CHANNEL, {"name": "new name 2"}, channel_id=channel.id), created_by_id=user.id)
505+
models.Change.create_change(generate_publish_channel_event(channel.id), created_by_id=user.id)
506+
507+
queryset = _unpublished_changes_query(channel)
508+
self.assertEqual(queryset.count(), 0)
509+
510+
def test_unpublished_changes_query_with_channel_object_no_publishable_since_publish(self):
511+
channel = testdata.channel()
512+
user = testdata.user()
513+
models.Change.create_change(generate_update_event(channel.id, CHANNEL, {"name": "new name"}, channel_id=channel.id), created_by_id=user.id)
514+
models.Change.create_change(generate_publish_channel_event(channel.id), created_by_id=user.id)
515+
models.Change.create_change(
516+
generate_update_event(
517+
channel.id,
518+
CHANNEL,
519+
{"name": "new name 2"},
520+
channel_id=channel.id
521+
),
522+
created_by_id=user.id,
523+
unpublishable=True,
524+
)
525+
526+
queryset = _unpublished_changes_query(channel)
527+
self.assertEqual(queryset.count(), 0)
528+
529+
def test_unpublished_changes_query_with_channel_object_no_publishable_since_publish_if_publish_fails_through_error(self):
530+
channel = testdata.channel()
531+
user = testdata.user()
532+
channel.main_tree = None
533+
channel.save()
534+
models.Change.create_change(generate_publish_channel_event(channel.id), created_by_id=user.id)
535+
536+
queryset = _unpublished_changes_query(channel)
537+
self.assertEqual(queryset.count(), 0)
538+
539+
def test_unpublished_changes_query_with_channel_object_no_publishable_since_publish_if_publish_fails_because_incomplete(self):
540+
channel = testdata.channel()
541+
user = testdata.user()
542+
channel.main_tree.complete = False
543+
channel.save()
544+
models.Change.create_change(generate_publish_channel_event(channel.id), created_by_id=user.id)
545+
546+
queryset = _unpublished_changes_query(channel)
547+
self.assertEqual(queryset.count(), 0)
548+
549+
def test_unpublished_changes_query_with_outerref(self):
550+
channel = testdata.channel()
551+
user = testdata.user()
552+
models.Change.create_change(generate_update_event(channel.id, CHANNEL, {"name": "new name"}, channel_id=channel.id), created_by_id=user.id)
553+
models.Change.create_change(generate_publish_channel_event(channel.id), created_by_id=user.id)
554+
models.Change.create_change(generate_update_event(channel.id, CHANNEL, {"name": "new name 2"}, channel_id=channel.id), created_by_id=user.id)
555+
556+
outer_ref = OuterRef("id")
557+
unpublished_changes = _unpublished_changes_query(outer_ref)
558+
channels = models.Channel.objects.filter(pk=channel.pk).annotate(unpublished_changes=Exists(unpublished_changes))
559+
self.assertTrue(channels[0].unpublished_changes)
560+
561+
def test_unpublished_changes_query_with_outerref_none_since_publish(self):
562+
channel = testdata.channel()
563+
user = testdata.user()
564+
models.Change.create_change(generate_update_event(channel.id, CHANNEL, {"name": "new name"}, channel_id=channel.id), created_by_id=user.id)
565+
models.Change.create_change(generate_update_event(channel.id, CHANNEL, {"name": "new name 2"}, channel_id=channel.id), created_by_id=user.id)
566+
models.Change.create_change(generate_publish_channel_event(channel.id), created_by_id=user.id)
567+
568+
outer_ref = OuterRef("id")
569+
unpublished_changes = _unpublished_changes_query(outer_ref)
570+
channels = models.Channel.objects.filter(pk=channel.pk).annotate(unpublished_changes=Exists(unpublished_changes))
571+
self.assertFalse(channels[0].unpublished_changes)
572+
573+
def test_unpublished_changes_query_with_outerref_no_publishable_since_publish(self):
574+
channel = testdata.channel()
575+
user = testdata.user()
576+
models.Change.create_change(generate_update_event(channel.id, CHANNEL, {"name": "new name"}, channel_id=channel.id), created_by_id=user.id)
577+
models.Change.create_change(generate_publish_channel_event(channel.id), created_by_id=user.id)
578+
models.Change.create_change(
579+
generate_update_event(
580+
channel.id,
581+
CHANNEL,
582+
{"name": "new name 2"},
583+
channel_id=channel.id
584+
),
585+
created_by_id=user.id,
586+
unpublishable=True
587+
)
588+
589+
outer_ref = OuterRef("id")
590+
unpublished_changes = _unpublished_changes_query(outer_ref)
591+
channels = models.Channel.objects.filter(pk=channel.pk).annotate(unpublished_changes=Exists(unpublished_changes))
592+
self.assertFalse(channels[0].unpublished_changes)
593+
594+
def test_unpublished_changes_query_no_publishable_since_publish_if_publish_fails_through_error(self):
595+
channel = testdata.channel()
596+
user = testdata.user()
597+
channel.main_tree = None
598+
channel.save()
599+
models.Change.create_change(generate_publish_channel_event(channel.id), created_by_id=user.id)
600+
601+
outer_ref = OuterRef("id")
602+
unpublished_changes = _unpublished_changes_query(outer_ref)
603+
channels = models.Channel.objects.filter(pk=channel.pk).annotate(unpublished_changes=Exists(unpublished_changes))
604+
self.assertFalse(channels[0].unpublished_changes)
605+
606+
def test_unpublished_changes_query_no_publishable_since_publish_if_publish_fails_because_incomplete(self):
607+
channel = testdata.channel()
608+
user = testdata.user()
609+
channel.main_tree.complete = False
610+
channel.save()
611+
models.Change.create_change(generate_publish_channel_event(channel.id), created_by_id=user.id)
612+
613+
outer_ref = OuterRef("id")
614+
unpublished_changes = _unpublished_changes_query(outer_ref)
615+
channels = models.Channel.objects.filter(pk=channel.pk).annotate(unpublished_changes=Exists(unpublished_changes))
616+
self.assertFalse(channels[0].unpublished_changes)

contentcuration/contentcuration/tests/viewsets/test_contentnode.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@
2727
from contentcuration.tests.viewsets.base import generate_copy_event
2828
from contentcuration.tests.viewsets.base import generate_create_event
2929
from contentcuration.tests.viewsets.base import generate_delete_event
30+
from contentcuration.tests.viewsets.base import generate_publish_channel_event
3031
from contentcuration.tests.viewsets.base import generate_update_event
3132
from contentcuration.tests.viewsets.base import generate_update_descendants_event
3233
from contentcuration.tests.viewsets.base import SyncTestMixin
3334
from contentcuration.utils.db_tools import TreeBuilder
35+
from contentcuration.viewsets.channel import _unpublished_changes_query
3436
from contentcuration.viewsets.contentnode import ContentNodeFilter
3537
from contentcuration.viewsets.sync.constants import CONTENTNODE
3638
from contentcuration.viewsets.sync.constants import CONTENTNODE_PREREQUISITE
@@ -1248,6 +1250,23 @@ def test_copy_contentnode(self):
12481250

12491251
self.assertEqual(new_node.parent_id, self.channel.main_tree_id)
12501252

1253+
def test_copy_contentnode_finalization_does_not_make_publishable(self):
1254+
self.channel.editors.add(self.user)
1255+
contentnode = models.ContentNode.objects.create(**self.contentnode_db_metadata)
1256+
new_node_id = uuid.uuid4().hex
1257+
response = self.sync_changes(
1258+
[
1259+
generate_copy_event(
1260+
new_node_id, CONTENTNODE, contentnode.id, self.channel.main_tree_id, channel_id=self.channel.id
1261+
),
1262+
# Save a published change for the channel, so that the finalization change will be generated
1263+
# after the publish change, and we can check that it is properly not making the channel appear publishable.
1264+
generate_publish_channel_event(self.channel.id),
1265+
],
1266+
)
1267+
self.assertEqual(response.status_code, 200, response.content)
1268+
self.assertEqual(_unpublished_changes_query(self.channel).count(), 0)
1269+
12511270
def test_cannot_copy_contentnode__source_permission(self):
12521271
source_channel = testdata.channel()
12531272
contentnode = create_and_get_contentnode(source_channel.main_tree_id)

contentcuration/contentcuration/views/internal.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,8 @@ def api_commit_channel(request):
236236
channel_id=channel_id,
237237
)
238238
# Send event (new staging tree or new main tree) for the channel
239-
Change.create_change(event, created_by_id=request.user.id)
239+
# mark is as unpublishable, as by itself a new staging tree is not a publishable change
240+
Change.create_change(event, created_by_id=request.user.id, unpublishable=True)
240241

241242
# Mark old staging tree for garbage collection
242243
if old_staging and old_staging != obj.main_tree:

contentcuration/contentcuration/viewsets/base.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -967,7 +967,8 @@ def update_progress(progress=None):
967967
custom_task_metadata_object.save()
968968

969969
Change.create_change(
970-
generate_update_event(pk, table, {TASK_ID: task_object.task_id}, channel_id=channel_id), applied=True
970+
# These changes are purely for ephemeral progress updating, and do not constitute a publishable change.
971+
generate_update_event(pk, table, {TASK_ID: task_object.task_id}, channel_id=channel_id), applied=True, unpublishable=True
971972
)
972973

973974
tracker = ProgressTracker(task_id, update_progress)
@@ -982,8 +983,9 @@ def update_progress(progress=None):
982983
finally:
983984
if task_object.status == states.STARTED:
984985
# No error reported, cleanup.
986+
# Mark as unpublishable, as this is a continuation of the progress updating, and not a publishable change.
985987
Change.create_change(
986-
generate_update_event(pk, table, {TASK_ID: None}, channel_id=channel_id), applied=True
988+
generate_update_event(pk, table, {TASK_ID: None}, channel_id=channel_id), applied=True, unpublishable=True
987989
)
988990
task_object.delete()
989991
custom_task_metadata_object.delete()

0 commit comments

Comments
 (0)