diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index e23399479f..6409e88285 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -821,33 +821,6 @@ class Resource extends mix(APIResource, IndexedDBResource) { }); } - createModel(data) { - return client.post(this.collectionUrl(), data).then(response => { - const now = Date.now(); - const data = response.data; - data[LAST_FETCHED] = now; - // Directly write to the table, rather than using the add method - // to avoid creating change events that we would sync back to the server. - return this.transaction({ mode: 'rw' }, () => { - return this.table.put(data).then(() => { - return data; - }); - }); - }); - } - - deleteModel(id) { - return client.delete(this.modelUrl(id)).then(() => { - // Directly write to the table, rather than using the delete method - // to avoid creating change events that we would sync back to the server. - return this.transaction({ mode: 'rw' }, () => { - return this.table.delete(id).then(() => { - return true; - }); - }); - }); - } - /** * @param {String} id * @param {Boolean} [doRefresh=true] -- Whether or not to refresh async from server @@ -878,6 +851,27 @@ class Resource extends mix(APIResource, IndexedDBResource) { } } +/** + * Resource that allows directly creating through the API, + * rather than through IndexedDB. API must explicitly support this. + */ +class CreateModelResource extends Resource { + createModel(data) { + return client.post(this.collectionUrl(), data).then(response => { + const now = Date.now(); + const data = response.data; + data[LAST_FETCHED] = now; + // Directly write to the table, rather than using the add method + // to avoid creating change events that we would sync back to the server. + return this.transaction({ mode: 'rw' }, () => { + return this.table.put(data).then(() => { + return data; + }); + }); + }); + } +} + /** * Tree resources mixin */ @@ -1018,7 +1012,7 @@ export const Bookmark = new Resource({ getUserId: getUserIdFromStore, }); -export const Channel = new Resource({ +export const Channel = new CreateModelResource({ tableName: TABLE_NAMES.CHANNEL, urlName: 'channel', indexFields: ['name', 'language'], @@ -1719,7 +1713,7 @@ export const ContentNode = new TreeResource({ }, }); -export const ChannelSet = new Resource({ +export const ChannelSet = new CreateModelResource({ tableName: TABLE_NAMES.CHANNELSET, urlName: 'channelset', getUserId: getUserIdFromStore, @@ -1757,7 +1751,7 @@ export const User = new Resource({ uuid: false, updateAsAdmin(id, changes) { - return client.patch(window.Urls.adminUsersDetail(id), changes).then(() => { + return client.post(window.Urls.adminUsersAccept(id)).then(() => { return this.transaction({ mode: 'rw' }, () => { return this.table.update(id, changes); }); diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index 0fb1b09a7c..b9e7864619 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -716,6 +716,32 @@ def exists(self, *filters): return Exists(self.queryset().filter(*filters).values("user_id")) +class ChannelModelQuerySet(models.QuerySet): + def create(self, **kwargs): + """ + Create a new object with the given kwargs, saving it to the database + and returning the created object. + Overriding the Django default here to allow passing through the actor_id + to register this event in the channel history. + """ + # Either allow the actor_id to be passed in, or read from a special attribute + # on the queryset, this makes super calls to other methods easier to handle + # without having to reimplement the entire method. + actor_id = kwargs.pop("actor_id", getattr(self, "_actor_id", None)) + obj = self.model(**kwargs) + self._for_write = True + obj.save(force_insert=True, using=self.db, actor_id=actor_id) + return obj + + def get_or_create(self, defaults=None, **kwargs): + self._actor_id = kwargs.pop("actor_id", None) + return super().get_or_create(defaults, **kwargs) + + def update_or_create(self, defaults=None, **kwargs): + self._actor_id = kwargs.pop("actor_id", None) + return super().update_or_create(defaults, **kwargs) + + class Channel(models.Model): """ Permissions come from association with organizations """ id = UUIDField(primary_key=True, default=uuid.uuid4) @@ -800,6 +826,8 @@ class Channel(models.Model): "version", ]) + objects = ChannelModelQuerySet.as_manager() + @classmethod def get_editable(cls, user, channel_id): return cls.filter_edit_queryset(cls.objects.all(), user).get(id=channel_id) @@ -874,6 +902,10 @@ def get_resource_size(self): return files['resource_size'] or 0 def on_create(self): + actor_id = getattr(self, "_actor_id", None) + if actor_id is None: + raise ValueError("No actor_id passed to save method") + if not self.content_defaults: self.content_defaults = DEFAULT_CONTENT_DEFAULTS @@ -905,7 +937,7 @@ def on_create(self): if self.public and (self.main_tree and self.main_tree.published): delete_public_channel_cache_keys() - def on_update(self): + def on_update(self): # noqa C901 from contentcuration.utils.user import calculate_user_storage original_values = self._field_updates.changed() @@ -929,14 +961,23 @@ def on_update(self): for editor in self.editors.all(): calculate_user_storage(editor.pk) - # Delete db if channel has been deleted and mark as unpublished if "deleted" in original_values and not original_values["deleted"]: self.pending_editors.all().delete() + # Delete db if channel has been deleted and mark as unpublished export_db_storage_path = os.path.join(settings.DB_ROOT, "{channel_id}.sqlite3".format(channel_id=self.id)) if default_storage.exists(export_db_storage_path): default_storage.delete(export_db_storage_path) if self.main_tree: self.main_tree.published = False + # mark the instance as deleted or recovered, if requested + if "deleted" in original_values: + user_id = getattr(self, "_actor_id", None) + if user_id is None: + raise ValueError("No actor_id passed to save method") + if original_values["deleted"]: + self.history.create(actor_id=user_id, action=channel_history.RECOVERY) + else: + self.history.create(actor_id=user_id, action=channel_history.DELETION) if self.main_tree and self.main_tree._field_updates.changed(): self.main_tree.save() @@ -946,13 +987,20 @@ def on_update(self): delete_public_channel_cache_keys() def save(self, *args, **kwargs): - if self._state.adding: + self._actor_id = kwargs.pop("actor_id", None) + creating = self._state.adding + if creating: + if self._actor_id is None: + raise ValueError("No actor_id passed to save method") self.on_create() else: self.on_update() super(Channel, self).save(*args, **kwargs) + if creating: + self.history.create(actor_id=self._actor_id, action=channel_history.CREATION) + def get_thumbnail(self): return get_channel_thumbnail(self) @@ -996,24 +1044,11 @@ def make_public(self, bypass_signals=False): return self - def mark_created(self, user): - self.history.create(actor_id=to_pk(user), action=channel_history.CREATION) - def mark_publishing(self, user): self.history.create(actor_id=to_pk(user), action=channel_history.PUBLICATION) self.main_tree.publishing = True self.main_tree.save() - def mark_deleted(self, user): - self.history.create(actor_id=to_pk(user), action=channel_history.DELETION) - self.deleted = True - self.save() - - def mark_recovered(self, user): - self.history.create(actor_id=to_pk(user), action=channel_history.RECOVERY) - self.deleted = False - self.save() - @property def deletion_history(self): return self.history.filter(action=channel_history.DELETION) diff --git a/contentcuration/contentcuration/tests/test_channel_model.py b/contentcuration/contentcuration/tests/test_channel_model.py index f4a79d2386..0014aeb41c 100755 --- a/contentcuration/contentcuration/tests/test_channel_model.py +++ b/contentcuration/contentcuration/tests/test_channel_model.py @@ -145,7 +145,7 @@ def test_returns_date_newer_when_node_added(self): # add a new node node( parent=self.channel.main_tree, - data={"node_id": "nodez", "title": "new child", "kind_id": "video",}, + data={"node_id": "nodez", "title": "new child", "kind_id": "video"}, ) # check that the returned date is newer assert self.channel.get_date_modified() > old_date @@ -160,7 +160,7 @@ def setUp(self): super(GetAllChannelsTestCase, self).setUp() # create 10 channels for comparison - self.channels = mixer.cycle(10).blend(Channel) + self.channels = [Channel.objects.create(actor_id=self.admin_user.id) for _ in range(10)] def test_returns_all_channels_in_the_db(self): """ @@ -179,9 +179,10 @@ class ChannelSetTestCase(BaseAPITestCase): def setUp(self): super(ChannelSetTestCase, self).setUp() self.channelset = mixer.blend(ChannelSet, editors=[self.user]) - self.channels = mixer.cycle(10).blend( - Channel, secret_tokens=[self.channelset.secret_token], editors=[self.user] - ) + self.channels = [Channel.objects.create(actor_id=self.user.id) for _ in range(10)] + for chann in self.channels: + chann.secret_tokens.add(self.channelset.secret_token) + chann.editors.add(self.user) def test_get_user_channel_sets(self): """ Make sure get_user_channel_sets returns the correct sets """ @@ -215,7 +216,7 @@ def test_channelset_deletion(self): def test_save_channels_to_token(self): """ Check endpoint will assign token to channels """ token = self.channelset.secret_token - channels = mixer.cycle(5).blend(Channel) + channels = [Channel.objects.create(actor_id=self.user.id) for _ in range(5)] channels = Channel.objects.filter( pk__in=[c.pk for c in channels] ) # Make this a queryset @@ -274,7 +275,7 @@ class ChannelMetadataSaveTestCase(StudioTestCase): def setUp(self): super(ChannelMetadataSaveTestCase, self).setUp() - self.channels = mixer.cycle(5).blend(Channel) + self.channels = [Channel.objects.create(actor_id=self.admin_user.id) for _ in range(5)] for c in self.channels: c.main_tree.changed = False c.main_tree.save() diff --git a/contentcuration/contentcuration/tests/test_contentnodes.py b/contentcuration/contentcuration/tests/test_contentnodes.py index 57496362cf..5dfad3d48d 100644 --- a/contentcuration/contentcuration/tests/test_contentnodes.py +++ b/contentcuration/contentcuration/tests/test_contentnodes.py @@ -876,7 +876,7 @@ def _setup_original_and_deriative_nodes(self): # Setup derivative channel self.new_channel = Channel.objects.create( - name="derivative of teschannel", source_id="lkajs" + name="derivative of teschannel", source_id="lkajs", actor_id=self.admin_user.id ) self.new_channel.save() self.new_channel.main_tree = self._create_empty_tree() diff --git a/contentcuration/contentcuration/tests/test_exportchannel.py b/contentcuration/contentcuration/tests/test_exportchannel.py index 90a8064f41..000ce58243 100644 --- a/contentcuration/contentcuration/tests/test_exportchannel.py +++ b/contentcuration/contentcuration/tests/test_exportchannel.py @@ -493,20 +493,21 @@ def tearDown(self): clear_tasks() def test_convert_channel_thumbnail_empty_thumbnail(self): - channel = cc.Channel.objects.create() + channel = cc.Channel.objects.create(actor_id=self.admin_user.id) self.assertEqual("", convert_channel_thumbnail(channel)) def test_convert_channel_thumbnail_static_thumbnail(self): - channel = cc.Channel.objects.create(thumbnail="/static/kolibri_flapping_bird.png") + channel = cc.Channel.objects.create(thumbnail="/static/kolibri_flapping_bird.png", actor_id=self.admin_user.id) self.assertEqual("", convert_channel_thumbnail(channel)) def test_convert_channel_thumbnail_encoding_valid(self): - channel = cc.Channel.objects.create(thumbnail="/content/kolibri_flapping_bird.png", thumbnail_encoding={"base64": "flappy_bird"}) + channel = cc.Channel.objects.create( + thumbnail="/content/kolibri_flapping_bird.png", thumbnail_encoding={"base64": "flappy_bird"}, actor_id=self.admin_user.id) self.assertEqual("flappy_bird", convert_channel_thumbnail(channel)) def test_convert_channel_thumbnail_encoding_invalid(self): with patch("contentcuration.utils.publish.get_thumbnail_encoding", return_value="this is a test"): - channel = cc.Channel.objects.create(thumbnail="/content/kolibri_flapping_bird.png", thumbnail_encoding={}) + channel = cc.Channel.objects.create(thumbnail="/content/kolibri_flapping_bird.png", thumbnail_encoding={}, actor_id=self.admin_user.id) self.assertEqual("this is a test", convert_channel_thumbnail(channel)) def test_create_slideshow_manifest(self): @@ -543,7 +544,7 @@ def tearDown(self): os.remove(self.output_db) def test_nonexistent_prerequisites(self): - channel = cc.Channel.objects.create() + channel = cc.Channel.objects.create(actor_id=self.admin_user.id) node1 = cc.ContentNode.objects.create(kind_id="exercise", parent_id=channel.main_tree.pk, complete=True) exercise = cc.ContentNode.objects.create(kind_id="exercise", complete=True) @@ -554,7 +555,7 @@ def test_nonexistent_prerequisites(self): class ChannelExportPublishedData(StudioTestCase): def test_fill_published_fields(self): version_notes = description() - channel = cc.Channel.objects.create() + channel = cc.Channel.objects.create(actor_id=self.admin_user.id) channel.last_published fill_published_fields(channel, version_notes) self.assertTrue(channel.published_data) diff --git a/contentcuration/contentcuration/tests/test_models.py b/contentcuration/contentcuration/tests/test_models.py index 262fb64752..0f910fb7ec 100644 --- a/contentcuration/contentcuration/tests/test_models.py +++ b/contentcuration/contentcuration/tests/test_models.py @@ -165,7 +165,7 @@ def test_filter_view_queryset__public_channel(self): def test_filter_view_queryset__public_channel__deleted(self): channel = self.public_channel channel.deleted = True - channel.save() + channel.save(actor_id=self.admin_user.id) queryset = Channel.filter_view_queryset(self.base_queryset, user=self.forbidden_user) self.assertQuerysetDoesNotContain(queryset, pk=channel.id) @@ -816,7 +816,7 @@ def _setup_user_related_data(self): user_b = self._create_user("b@tester.com") # Create a sole editor non-public channel. - sole_editor_channel = Channel.objects.create(name="sole-editor") + sole_editor_channel = Channel.objects.create(name="sole-editor", actor_id=user_a.id) sole_editor_channel.editors.add(user_a) # Create sole-editor channel nodes. @@ -944,18 +944,20 @@ def setUp(self): self.channel = testdata.channel() def test_mark_channel_created(self): - self.assertEqual(0, self.channel.history.filter(action=channel_history.CREATION).count()) - self.channel.mark_created(self.admin_user) - self.assertEqual(1, self.channel.history.filter(actor=self.admin_user, action=channel_history.CREATION).count()) + self.assertEqual(1, self.channel.history.filter(action=channel_history.CREATION).count()) def test_mark_channel_deleted(self): self.assertEqual(0, self.channel.deletion_history.count()) - self.channel.mark_deleted(self.admin_user) + self.channel.deleted = True + self.channel.save(actor_id=self.admin_user.id) self.assertEqual(1, self.channel.deletion_history.filter(actor=self.admin_user).count()) def test_mark_channel_recovered(self): self.assertEqual(0, self.channel.history.filter(actor=self.admin_user, action=channel_history.RECOVERY).count()) - self.channel.mark_recovered(self.admin_user) + self.channel.deleted = True + self.channel.save(actor_id=self.admin_user.id) + self.channel.deleted = False + self.channel.save(actor_id=self.admin_user.id) self.assertEqual(1, self.channel.history.filter(actor=self.admin_user, action=channel_history.RECOVERY).count()) def test_prune(self): @@ -966,6 +968,7 @@ def test_prune(self): testdata.channel() ] last_history_ids = [] + ChannelHistory.objects.all().delete() self.assertEqual(0, ChannelHistory.objects.count()) diff --git a/contentcuration/contentcuration/tests/test_restore_channel.py b/contentcuration/contentcuration/tests/test_restore_channel.py index 479c1cadce..a4d1e13a39 100644 --- a/contentcuration/contentcuration/tests/test_restore_channel.py +++ b/contentcuration/contentcuration/tests/test_restore_channel.py @@ -93,7 +93,7 @@ def setUp(self, thumb_mock): self.version, self.last_updated, ) - self.channel, _ = create_channel(self.cursor_mock, self.id) + self.channel, _ = create_channel(self.cursor_mock, self.id, self.admin_user) def test_restore_channel_id(self): self.assertEqual(self.channel.id, self.id) diff --git a/contentcuration/contentcuration/tests/test_serializers.py b/contentcuration/contentcuration/tests/test_serializers.py index 1eed06db9e..dcb9fea978 100644 --- a/contentcuration/contentcuration/tests/test_serializers.py +++ b/contentcuration/contentcuration/tests/test_serializers.py @@ -2,6 +2,7 @@ from django.db.models.query import QuerySet from le_utils.constants import content_kinds +from mock import Mock from rest_framework import serializers from .base import BaseAPITestCase @@ -146,12 +147,15 @@ class Meta: nested_writes = True def test_save__create(self): + request = Mock() + request.user = self.user s = self.ChannelSerializer( data=dict( name="New test channel", description="This is the best test channel", content_defaults=dict(author="Buster"), - ) + ), + context=dict(request=request), ) self.assertTrue(s.is_valid()) @@ -167,7 +171,7 @@ def test_save__update(self): description="This is the best test channel", content_defaults=dict(author="Buster"), ) - c.save() + c.save(actor_id=self.user.id) s = self.ChannelSerializer( c, data=dict(content_defaults=dict(license="Special Permissions")) diff --git a/contentcuration/contentcuration/tests/test_sync.py b/contentcuration/contentcuration/tests/test_sync.py index b03d34b4c6..3a2ba590c5 100644 --- a/contentcuration/contentcuration/tests/test_sync.py +++ b/contentcuration/contentcuration/tests/test_sync.py @@ -37,7 +37,7 @@ class SyncTestCase(StudioTestCase): def setUp(self): super(SyncTestCase, self).setUpBase() - self.derivative_channel = Channel.objects.create(name="testchannel") + self.derivative_channel = Channel.objects.create(name="testchannel", actor_id=self.admin_user.id) self.channel.main_tree.copy_to(self.derivative_channel.main_tree) self.derivative_channel.main_tree.refresh_from_db() self.derivative_channel.save() diff --git a/contentcuration/contentcuration/tests/testdata.py b/contentcuration/contentcuration/tests/testdata.py index 3592c9df17..57a3b50b5d 100644 --- a/contentcuration/contentcuration/tests/testdata.py +++ b/contentcuration/contentcuration/tests/testdata.py @@ -211,7 +211,8 @@ def tree(parent=None): def channel(name="testchannel"): - channel = cc.Channel.objects.create(name=name) + channel_creator = user() + channel = cc.Channel.objects.create(name=name, actor_id=channel_creator.id) channel.save() channel.main_tree = tree() diff --git a/contentcuration/contentcuration/tests/views/test_views_internal.py b/contentcuration/contentcuration/tests/views/test_views_internal.py index 3a23654ee6..f8f80cdcf6 100644 --- a/contentcuration/contentcuration/tests/views/test_views_internal.py +++ b/contentcuration/contentcuration/tests/views/test_views_internal.py @@ -486,7 +486,7 @@ def test_200_publish_successful(self): del connections.databases[alias] def test_404_not_authorized(self): - new_channel = Channel.objects.create() + new_channel = Channel.objects.create(actor_id=self.user.id) response = self.post( reverse_lazy("api_publish_channel"), {"channel_id": new_channel.id} ) @@ -637,7 +637,7 @@ def test_200_post(self): self.assertEqual(response.status_code, 200) def test_404_no_permission(self): - new_channel = Channel.objects.create() + new_channel = Channel.objects.create(actor_id=self.user.id) response = self.post( reverse_lazy("api_finish_channel"), {"channel_id": new_channel.id} ) @@ -652,7 +652,7 @@ def test_200_post(self): self.assertEqual(response.status_code, 200) def test_404_no_permission(self): - new_channel = Channel.objects.create() + new_channel = Channel.objects.create(actor_id=self.user.id) response = self.post( reverse_lazy("check_user_is_editor"), {"channel_id": new_channel.id} ) @@ -668,7 +668,7 @@ def test_200_post(self): self.assertEqual(response.status_code, 200) def test_404_no_permission(self): - new_channel = Channel.objects.create() + new_channel = Channel.objects.create(actor_id=self.user.id) response = self.post( reverse_lazy("get_tree_data"), {"channel_id": new_channel.id, "tree": "main"}, @@ -685,7 +685,7 @@ def test_200_post(self): self.assertEqual(response.status_code, 200) def test_404_no_permission(self): - new_channel = Channel.objects.create() + new_channel = Channel.objects.create(actor_id=self.user.id) response = self.post( reverse_lazy("get_node_tree_data"), {"channel_id": new_channel.id, "tree": "main"}, @@ -701,7 +701,7 @@ def test_200_post(self): self.assertEqual(response.status_code, 200) def test_404_no_permission(self): - new_channel = Channel.objects.create() + new_channel = Channel.objects.create(actor_id=self.user.id) response = self.post( reverse_lazy("get_channel_status_bulk"), {"channel_ids": [self.channel.id, new_channel.id]}, diff --git a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py index dd80e09291..aff73eeb38 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py +++ b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py @@ -67,6 +67,36 @@ def test_create_assessmentitem(self): except models.AssessmentItem.DoesNotExist: self.fail("AssessmentItem was not created") + def test_create_assessmentitem_no_node_permission(self): + self.client.force_authenticate(user=self.user) + new_channel = testdata.channel() + new_channel_exercise = ( + new_channel.main_tree.get_descendants() + .filter(kind_id=content_kinds.EXERCISE) + .first() + .id + ) + assessmentitem = self.assessmentitem_metadata + assessmentitem["contentnode"] = new_channel_exercise + response = self.sync_changes( + [ + generate_create_event( + [assessmentitem["contentnode"], assessmentitem["assessment_id"]], + ASSESSMENTITEM, + assessmentitem, + channel_id=new_channel.id, + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + try: + models.AssessmentItem.objects.get( + assessment_id=assessmentitem["assessment_id"] + ) + self.fail("AssessmentItem was created") + except models.AssessmentItem.DoesNotExist: + pass + def test_create_assessmentitem_with_file_question(self): self.client.force_authenticate(user=self.user) assessmentitem = self.assessmentitem_metadata @@ -640,78 +670,7 @@ def test_create_assessmentitem(self): response = self.client.post( reverse("assessmentitem-list"), assessmentitem, format="json", ) - self.assertEqual(response.status_code, 201, response.content) - try: - models.AssessmentItem.objects.get( - assessment_id=assessmentitem["assessment_id"] - ) - except models.AssessmentItem.DoesNotExist: - self.fail("AssessmentItem was not created") - - def test_create_assessmentitem_no_node_permission(self): - self.client.force_authenticate(user=self.user) - new_channel = testdata.channel() - new_channel_exercise = ( - new_channel.main_tree.get_descendants() - .filter(kind_id=content_kinds.EXERCISE) - .first() - .id - ) - assessmentitem = self.assessmentitem_metadata - assessmentitem["contentnode"] = new_channel_exercise - response = self.client.post( - reverse("assessmentitem-list"), assessmentitem, format="json", - ) - self.assertEqual(response.status_code, 400, response.content) - - def test_create_assessmentitem_with_file(self): - self.client.force_authenticate(user=self.user) - assessmentitem = self.assessmentitem_metadata - image_file = testdata.fileobj_exercise_image() - image_file.uploaded_by = self.user - image_file.save() - question = "![alt_text](${}/{}.{})".format( - exercises.IMG_PLACEHOLDER, image_file.checksum, image_file.file_format_id - ) - assessmentitem["question"] = question - response = self.client.post( - reverse("assessmentitem-list"), assessmentitem, format="json", - ) - self.assertEqual(response.status_code, 201, response.content) - try: - ai = models.AssessmentItem.objects.get( - assessment_id=assessmentitem["assessment_id"] - ) - except models.AssessmentItem.DoesNotExist: - self.fail("AssessmentItem was not created") - - try: - file = ai.files.get() - self.assertEqual(file.id, image_file.id) - except models.File.DoesNotExist: - self.fail("File was not updated") - - def test_create_assessmentitem_with_file_no_permission(self): - self.client.force_authenticate(user=self.user) - assessmentitem = self.assessmentitem_metadata - image_file = testdata.fileobj_exercise_image() - question = "![alt_text](${}/{}.{})".format( - exercises.IMG_PLACEHOLDER, image_file.checksum, image_file.file_format_id - ) - assessmentitem["question"] = question - response = self.client.post( - reverse("assessmentitem-list"), assessmentitem, format="json", - ) - self.assertEqual(response.status_code, 400, response.content) - try: - models.AssessmentItem.objects.get( - assessment_id=assessmentitem["assessment_id"] - ) - self.fail("AssessmentItem was created") - except models.AssessmentItem.DoesNotExist: - pass - - self.assertIsNone(image_file.assessment_item) + self.assertEqual(response.status_code, 405, response.content) def test_update_assessmentitem(self): assessmentitem = models.AssessmentItem.objects.create( @@ -725,109 +684,7 @@ def test_update_assessmentitem(self): {"question": new_question}, format="json", ) - self.assertEqual(response.status_code, 200, response.content) - self.assertEqual( - models.AssessmentItem.objects.get(id=assessmentitem.id).question, - new_question, - ) - - def test_update_assessmentitem_empty(self): - - assessmentitem = models.AssessmentItem.objects.create( - **self.assessmentitem_db_metadata - ) - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}), - {}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - - def test_update_assessmentitem_unwriteable_fields(self): - - assessmentitem = models.AssessmentItem.objects.create( - **self.assessmentitem_db_metadata - ) - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}), - {"not_a_field": "not_a_value"}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - - def test_update_assessmentitem_with_file(self): - - assessmentitem = models.AssessmentItem.objects.create( - **self.assessmentitem_db_metadata - ) - image_file = testdata.fileobj_exercise_image() - image_file.uploaded_by = self.user - image_file.save() - question = "![alt_text](${}/{}.{})".format( - exercises.IMG_PLACEHOLDER, image_file.checksum, image_file.file_format_id - ) - - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}), - {"question": question}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - try: - file = assessmentitem.files.get() - self.assertEqual(file.id, image_file.id) - except models.File.DoesNotExist: - self.fail("File was not updated") - - def test_update_assessmentitem_with_file_no_permissions(self): - - assessmentitem = models.AssessmentItem.objects.create( - **self.assessmentitem_db_metadata - ) - image_file = testdata.fileobj_exercise_image() - question = "![alt_text](${}/{}.{})".format( - exercises.IMG_PLACEHOLDER, image_file.checksum, image_file.file_format_id - ) - - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}), - {"question": question}, - format="json", - ) - self.assertEqual(response.status_code, 400, response.content) - try: - file = assessmentitem.files.get() - self.assertNotEqual(file.id, image_file.id) - self.fail("File was updated") - except models.File.DoesNotExist: - pass - - def test_update_assessmentitem_remove_file(self): - - assessmentitem = models.AssessmentItem.objects.create( - **self.assessmentitem_db_metadata - ) - image_file = testdata.fileobj_exercise_image() - image_file.assessment_item = assessmentitem - image_file.save() - question = "A different question" - - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}), - {"question": question}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - try: - assessmentitem.files.get() - self.fail("File was not removed") - except models.File.DoesNotExist: - pass + self.assertEqual(response.status_code, 405, response.content) def test_delete_assessmentitem(self): assessmentitem = models.AssessmentItem.objects.create( @@ -838,12 +695,7 @@ def test_delete_assessmentitem(self): response = self.client.delete( reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}) ) - self.assertEqual(response.status_code, 204, response.content) - try: - models.AssessmentItem.objects.get(id=assessmentitem.id) - self.fail("AssessmentItem was not deleted") - except models.AssessmentItem.DoesNotExist: - pass + self.assertEqual(response.status_code, 405, response.content) class ContentIDTestCase(SyncTestMixin, StudioAPITestCase): diff --git a/contentcuration/contentcuration/tests/viewsets/test_bookmark.py b/contentcuration/contentcuration/tests/viewsets/test_bookmark.py index 16ac1dfff4..04d53cd756 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_bookmark.py +++ b/contentcuration/contentcuration/tests/viewsets/test_bookmark.py @@ -179,13 +179,7 @@ def test_create_bookmark(self): response = self.client.post( reverse("bookmark-list"), bookmark, format="json", ) - self.assertEqual(response.status_code, 201, response.content) - try: - models.Channel.bookmarked_by.through.objects.get( - user=self.user - ) - except models.Channel.bookmarked_by.through.DoesNotExist: - self.fail("Bookmark was not created") + self.assertEqual(response.status_code, 405, response.content) def test_delete_bookmark(self): bookmark = models.Channel.bookmarked_by.through.objects.create( @@ -196,9 +190,4 @@ def test_delete_bookmark(self): response = self.client.delete( reverse("bookmark-detail", kwargs={"pk": bookmark.id}) ) - self.assertEqual(response.status_code, 204, response.content) - try: - models.Channel.bookmarked_by.through.objects.get(id=bookmark.id) - self.fail("Bookmark was not deleted") - except models.Channel.bookmarked_by.through.DoesNotExist: - pass + self.assertEqual(response.status_code, 405, response.content) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channel.py b/contentcuration/contentcuration/tests/viewsets/test_channel.py index 9c49551ba8..505fbd836b 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channel.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channel.py @@ -8,6 +8,7 @@ from contentcuration import models from contentcuration import models as cc +from contentcuration.constants import channel_history from contentcuration.tests import testdata from contentcuration.tests.base import StudioAPITestCase from contentcuration.tests.viewsets.base import generate_create_event @@ -66,7 +67,7 @@ def test_create_channels(self): def test_update_channel(self): user = testdata.user() - channel = models.Channel.objects.create(**self.channel_metadata) + channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel.editors.add(user) new_name = "This is not the old name" @@ -79,7 +80,7 @@ def test_update_channel(self): def test_update_channel_thumbnail_encoding(self): user = testdata.user() - channel = models.Channel.objects.create(**self.channel_metadata) + channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel.editors.add(user) new_encoding = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAfQA" self.client.force_authenticate(user=user) @@ -97,7 +98,7 @@ def test_update_channel_thumbnail_encoding(self): def test_cannot_update_channel(self): user = testdata.user() - channel = models.Channel.objects.create(**self.channel_metadata) + channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) new_name = "This is not the old name" self.client.force_authenticate(user=user) @@ -109,7 +110,7 @@ def test_cannot_update_channel(self): def test_viewer_cannot_update_channel(self): user = testdata.user() - channel = models.Channel.objects.create(**self.channel_metadata) + channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel.viewers.add(user) new_name = "This is not the old name" @@ -122,7 +123,7 @@ def test_viewer_cannot_update_channel(self): def test_update_channel_defaults(self): user = testdata.user() - channel = models.Channel.objects.create(**self.channel_metadata) + channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel.editors.add(user) author = "This is not the old author" @@ -160,9 +161,9 @@ def test_update_channel_defaults(self): def test_update_channels(self): user = testdata.user() - channel1 = models.Channel.objects.create(**self.channel_metadata) + channel1 = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel1.editors.add(user) - channel2 = models.Channel.objects.create(**self.channel_metadata) + channel2 = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel2.editors.add(user) new_name = "This is not the old name" @@ -179,9 +180,9 @@ def test_update_channels(self): def test_cannot_update_some_channels(self): user = testdata.user() - channel1 = models.Channel.objects.create(**self.channel_metadata) + channel1 = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel1.editors.add(user) - channel2 = models.Channel.objects.create(**self.channel_metadata) + channel2 = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) new_name = "This is not the old name" self.client.force_authenticate(user=user) @@ -197,9 +198,9 @@ def test_cannot_update_some_channels(self): def test_viewer_cannot_update_some_channels(self): user = testdata.user() - channel1 = models.Channel.objects.create(**self.channel_metadata) + channel1 = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel1.editors.add(user) - channel2 = models.Channel.objects.create(**self.channel_metadata) + channel2 = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel2.viewers.add(user) new_name = "This is not the old name" @@ -216,17 +217,19 @@ def test_viewer_cannot_update_some_channels(self): def test_delete_channel(self): user = testdata.user() - channel = models.Channel.objects.create(**self.channel_metadata) + channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel.editors.add(user) self.client.force_authenticate(user=user) response = self.sync_changes([generate_delete_event(channel.id, CHANNEL, channel_id=channel.id)]) self.assertEqual(response.status_code, 200, response.content) - self.assertTrue(models.Channel.objects.get(id=channel.id).deleted) + channel = models.Channel.objects.get(id=channel.id) + self.assertTrue(channel.deleted) + self.assertEqual(1, channel.deletion_history.filter(actor=user).count()) def test_cannot_delete_channel(self): user = testdata.user() - channel = models.Channel.objects.create(**self.channel_metadata) + channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) self.client.force_authenticate(user=user) response = self.sync_changes( @@ -242,10 +245,10 @@ def test_cannot_delete_channel(self): def test_delete_channels(self): user = testdata.user() - channel1 = models.Channel.objects.create(**self.channel_metadata) + channel1 = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel1.editors.add(user) - channel2 = models.Channel.objects.create(**self.channel_metadata) + channel2 = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel2.editors.add(user) self.client.force_authenticate(user=user) @@ -261,9 +264,9 @@ def test_delete_channels(self): def test_cannot_delete_some_channels(self): user = testdata.user() - channel1 = models.Channel.objects.create(**self.channel_metadata) + channel1 = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel1.editors.add(user) - channel2 = models.Channel.objects.create(**self.channel_metadata) + channel2 = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) self.client.force_authenticate(user=user) response = self.sync_changes( @@ -382,8 +385,8 @@ def channel_metadata(self): } def test_fetch_channel_for_admin(self): - channel = models.Channel.objects.create(**self.channel_metadata) user = testdata.user() + channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) user.is_admin = True user.save() self.client.force_authenticate(user=user) @@ -393,8 +396,8 @@ def test_fetch_channel_for_admin(self): self.assertEqual(response.status_code, 200, response.content) def test_fetch_admin_channels_invalid_filter(self): - models.Channel.objects.create(**self.channel_metadata) user = testdata.user() + models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) user.is_admin = True user.is_staff = True user.save() @@ -417,7 +420,7 @@ def test_create_channel(self): def test_update_channel(self): user = testdata.user() - channel = models.Channel.objects.create(**self.channel_metadata) + channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel.editors.add(user) new_name = "This is not the old name" @@ -427,12 +430,11 @@ def test_update_channel(self): {"name": new_name}, format="json", ) - self.assertEqual(response.status_code, 200, response.content) - self.assertEqual(models.Channel.objects.get(id=channel.id).name, new_name) + self.assertEqual(response.status_code, 405, response.content) def test_delete_channel(self): user = testdata.user() - channel = models.Channel.objects.create(**self.channel_metadata) + channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) channel.editors.add(user) self.client.force_authenticate(user=user) @@ -440,4 +442,27 @@ def test_delete_channel(self): reverse("channel-detail", kwargs={"pk": channel.id}) ) self.assertEqual(response.status_code, 204, response.content) - self.assertTrue(models.Channel.objects.get(id=channel.id).deleted) + channel = models.Channel.objects.get(id=channel.id) + self.assertTrue(channel.deleted) + self.assertEqual(1, channel.deletion_history.filter(actor=user).count()) + + def test_admin_restore_channel(self): + user = testdata.user() + user.is_admin = True + user.is_staff = True + user.save() + channel = models.Channel.objects.create(actor_id=user.id, **self.channel_metadata) + channel.editors.add(user) + channel.deleted = True + channel.save(actor_id=user.id) + + self.client.force_authenticate(user=user) + response = self.client.patch( + reverse("admin-channels-detail", kwargs={"pk": channel.id}), + {"deleted": False}, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + channel = models.Channel.objects.get(id=channel.id) + self.assertFalse(channel.deleted) + self.assertEqual(1, channel.history.filter(actor=user, action=channel_history.RECOVERY).count()) diff --git a/contentcuration/contentcuration/tests/viewsets/test_channelset.py b/contentcuration/contentcuration/tests/viewsets/test_channelset.py index 7205eb38f4..19ec846f11 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_channelset.py +++ b/contentcuration/contentcuration/tests/viewsets/test_channelset.py @@ -334,36 +334,7 @@ def test_update_channelset(self): {"channels": {self.channel.id: True}}, format="json", ) - self.assertEqual(response.status_code, 200, response.content) - self.assertTrue( - models.ChannelSet.objects.get(id=channelset.id) - .secret_token.channels.filter(pk=self.channel.id) - .exists() - ) - - def test_update_channelset_empty(self): - - channelset = models.ChannelSet.objects.create(**self.channelset_db_metadata) - channelset.editors.add(self.user) - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("channelset-detail", kwargs={"pk": channelset.id}), - {}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - - def test_update_channelset_unwriteable_fields(self): - - channelset = models.ChannelSet.objects.create(**self.channelset_db_metadata) - channelset.editors.add(self.user) - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("channelset-detail", kwargs={"pk": channelset.id}), - {"not_a_field": "not_a_value"}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.status_code, 405, response.content) def test_delete_channelset(self): channelset = models.ChannelSet.objects.create(**self.channelset_db_metadata) @@ -373,9 +344,4 @@ def test_delete_channelset(self): response = self.client.delete( reverse("channelset-detail", kwargs={"pk": channelset.id}) ) - self.assertEqual(response.status_code, 204, response.content) - try: - models.ChannelSet.objects.get(id=channelset.id) - self.fail("ChannelSet was not deleted") - except models.ChannelSet.DoesNotExist: - pass + self.assertEqual(response.status_code, 405, response.content) diff --git a/contentcuration/contentcuration/tests/viewsets/test_clipboard.py b/contentcuration/contentcuration/tests/viewsets/test_clipboard.py index 2f497c2a04..6621e3ed6a 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_clipboard.py +++ b/contentcuration/contentcuration/tests/viewsets/test_clipboard.py @@ -263,11 +263,7 @@ def test_create_clipboard(self): response = self.client.post( reverse("clipboard-list"), clipboard, format="json", ) - self.assertEqual(response.status_code, 201, response.content) - try: - models.ContentNode.objects.get(id=clipboard["id"]) - except models.ContentNode.DoesNotExist: - self.fail("ContentNode was not created") + self.assertEqual(response.status_code, 405, response.content) def test_delete_clipboard(self): clipboard = models.ContentNode.objects.create(**self.clipboard_db_metadata) @@ -276,9 +272,4 @@ def test_delete_clipboard(self): response = self.client.delete( reverse("clipboard-detail", kwargs={"pk": clipboard.id}) ) - self.assertEqual(response.status_code, 204, response.content) - try: - models.ContentNode.objects.get(id=clipboard.id) - self.fail("ContentNode was not deleted") - except models.ContentNode.DoesNotExist: - pass + self.assertEqual(response.status_code, 405, response.content) diff --git a/contentcuration/contentcuration/tests/viewsets/test_contentnode.py b/contentcuration/contentcuration/tests/viewsets/test_contentnode.py index 882b5644a9..11d4dd6147 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_contentnode.py +++ b/contentcuration/contentcuration/tests/viewsets/test_contentnode.py @@ -933,6 +933,25 @@ def test_update_contentnode_tags(self): .exists() ) + def test_update_contentnode_tag_greater_than_30_chars(self): + + contentnode = models.ContentNode.objects.create(**self.contentnode_db_metadata) + tag = "kolibri studio, kolibri studio!" + + response = self.sync_changes( + [ + generate_update_event( + contentnode.id, CONTENTNODE, {"tags.{}".format(tag): True}, channel_id=self.channel.id + ) + ], + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertFalse( + models.ContentNode.objects.get(id=contentnode.id) + .tags.filter(tag_name=tag) + .exists() + ) + def test_update_contentnode_suggested_duration(self): contentnode = models.ContentNode.objects.create(**self.contentnode_db_metadata) new_suggested_duration = 600 @@ -1424,7 +1443,7 @@ def test_fetch_contentnode(self): def test_fetch_contentnode__by_parent(self): - channel = models.Channel.objects.create(name="Test channel") + channel = models.Channel.objects.create(actor_id=self.user.id, name="Test channel") channel.editors.add(self.user) channel.save() @@ -1440,7 +1459,7 @@ def test_fetch_contentnode__by_parent(self): self.assertEqual(response.data[0]["id"], contentnode.id) def test_fetch_contentnode__by_node_id_channel_id(self): - channel = models.Channel.objects.create(name="Test channel") + channel = models.Channel.objects.create(actor_id=self.user.id, name="Test channel") channel.editors.add(self.user) channel.save() @@ -1500,42 +1519,7 @@ def test_create_contentnode(self): response = self.client.post( reverse("contentnode-list"), contentnode, format="json", ) - self.assertEqual(response.status_code, 201, response.content) - try: - models.ContentNode.objects.get(id=contentnode["id"]) - except models.ContentNode.DoesNotExist: - self.fail("ContentNode was not created") - - def test_create_contentnode_tag(self): - tag = "howzat!" - - contentnode = self.contentnode_metadata - contentnode["tags"] = { - tag: True, - } - response = self.client.post( - reverse("contentnode-list"), contentnode, format="json", - ) - self.assertEqual(response.status_code, 201, response.content) - self.assertTrue( - models.ContentNode.objects.get(id=contentnode["id"]) - .tags.filter(tag_name=tag) - .exists() - ) - - def test_contentnode_tag_greater_than_30_chars(self): - tag = "kolibri studio, kolibri studio!" - - contentnode = self.contentnode_metadata - contentnode["tags"] = { - tag: True, - } - - response = self.client.post( - reverse("contentnode-list"), contentnode, format="json", - ) - - self.assertEqual(response.status_code, 400, response.content) + self.assertEqual(response.status_code, 405, response.content) def test_update_contentnode(self): contentnode = models.ContentNode.objects.create(**self.contentnode_db_metadata) @@ -1546,96 +1530,7 @@ def test_update_contentnode(self): {"title": new_title}, format="json", ) - self.assertEqual(response.status_code, 200, response.content) - self.assertEqual( - models.ContentNode.objects.get(id=contentnode.id).title, new_title - ) - - def test_update_contentnode_tags(self): - contentnode = models.ContentNode.objects.create(**self.contentnode_db_metadata) - tag = "howzat!" - - response = self.client.patch( - reverse("contentnode-detail", kwargs={"pk": contentnode.id}), - {"tags.{}".format(tag): True}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - self.assertTrue( - models.ContentNode.objects.get(id=contentnode.id) - .tags.filter(tag_name=tag) - .exists() - ) - - other_tag = "LBW!" - - response = self.client.patch( - reverse("contentnode-detail", kwargs={"pk": contentnode.id}), - {"tags.{}".format(other_tag): True}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - self.assertTrue( - models.ContentNode.objects.get(id=contentnode.id) - .tags.filter(tag_name=tag) - .exists() - ) - self.assertTrue( - models.ContentNode.objects.get(id=contentnode.id) - .tags.filter(tag_name=other_tag) - .exists() - ) - - response = self.client.patch( - reverse("contentnode-detail", kwargs={"pk": contentnode.id}), - {"tags.{}".format(other_tag): None}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - self.assertTrue( - models.ContentNode.objects.get(id=contentnode.id) - .tags.filter(tag_name=tag) - .exists() - ) - self.assertFalse( - models.ContentNode.objects.get(id=contentnode.id) - .tags.filter(tag_name=other_tag) - .exists() - ) - - def test_update_contentnode_suggested_duration(self): - user = testdata.user() - contentnode = models.ContentNode.objects.create(**self.contentnode_db_metadata) - new_suggested_duration = 600 - - self.client.force_authenticate(user=user) - response = self.client.patch( - reverse("contentnode-detail", kwargs={"pk": contentnode.id}), - {"suggested_duration": new_suggested_duration}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - self.assertEqual( - models.ContentNode.objects.get(id=contentnode.id).suggested_duration, new_suggested_duration - ) - - def test_update_contentnode_tags_dont_duplicate(self): - contentnode = models.ContentNode.objects.create(**self.contentnode_db_metadata) - tag = "howzat!" - - old_tag = models.ContentTag.objects.create(tag_name=tag) - - response = self.client.patch( - reverse("contentnode-detail", kwargs={"pk": contentnode.id}), - {"tags.{}".format(tag): True}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - self.assertTrue( - models.ContentNode.objects.get(id=contentnode.id) - .tags.filter(id=old_tag.id) - .exists() - ) + self.assertEqual(response.status_code, 405, response.content) def test_delete_contentnode(self): contentnode = models.ContentNode.objects.create(**self.contentnode_db_metadata) @@ -1643,66 +1538,7 @@ def test_delete_contentnode(self): response = self.client.delete( reverse("contentnode-detail", kwargs={"pk": contentnode.id}) ) - self.assertEqual(response.status_code, 204, response.content) - try: - models.ContentNode.objects.get(id=contentnode.id) - self.fail("ContentNode was not deleted") - except models.ContentNode.DoesNotExist: - pass - - def test_create_contentnode_moveable(self): - """ - Regression test to ensure that nodes created here are able to be moved to - other MPTT trees without invalidating data. - """ - contentnode = self.contentnode_metadata - response = self.client.post( - reverse("contentnode-list"), contentnode, format="json", - ) - self.assertEqual(response.status_code, 201, response.content) - try: - new_node = models.ContentNode.objects.get(id=contentnode["id"]) - except models.ContentNode.DoesNotExist: - self.fail("ContentNode was not created") - - new_root = models.ContentNode.objects.create( - title="Aron's cool contentnode", - kind_id=content_kinds.VIDEO, - description="coolest contentnode this side of the Pacific", - ) - - new_node.move_to(new_root, "last-child") - - try: - new_node.get_root() - except models.ContentNode.MultipleObjectsReturned: - self.fail("Moving caused a breakdown of the tree structure") - - def test_update_orphanage_root(self): - new_title = "This is not the old title" - - response = self.client.patch( - reverse("contentnode-detail", kwargs={"pk": settings.ORPHANAGE_ROOT_ID}), - {"title": new_title}, - format="json", - ) - self.assertEqual(response.status_code, 404, response.content) - self.assertNotEqual( - models.ContentNode.objects.get(id=settings.ORPHANAGE_ROOT_ID).title, - new_title, - ) - - def test_delete_orphanage_root(self): - models.ContentNode.objects.create(**self.contentnode_db_metadata) - - response = self.client.delete( - reverse("contentnode-detail", kwargs={"pk": settings.ORPHANAGE_ROOT_ID}) - ) - self.assertEqual(response.status_code, 404, response.content) - try: - models.ContentNode.objects.get(id=settings.ORPHANAGE_ROOT_ID) - except models.ContentNode.DoesNotExist: - self.fail("Orphanage root was deleted") + self.assertEqual(response.status_code, 405, response.content) @mock.patch("contentcuration.utils.nodes.STALE_MAX_CALCULATION_SIZE", 5000) def test_resource_size(self): diff --git a/contentcuration/contentcuration/tests/viewsets/test_file.py b/contentcuration/contentcuration/tests/viewsets/test_file.py index 2cd0740e59..dd5e010552 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_file.py +++ b/contentcuration/contentcuration/tests/viewsets/test_file.py @@ -137,6 +137,35 @@ def test_update_file_no_channel_edit_permission(self): models.File.objects.get(id=file.id).preset_id, new_preset, ) + def test_update_file_no_node_permission(self): + file = models.File.objects.create(**self.file_db_metadata) + new_channel = testdata.channel() + new_channel_node = new_channel.main_tree.get_descendants().first().id + + self.sync_changes( + [generate_update_event(file.id, FILE, {"contentnode": new_channel_node}, channel_id=self.channel.id)], + ) + self.assertNotEqual( + models.File.objects.get(id=file.id).contentnode, new_channel_node, + ) + + def test_update_file_no_assessmentitem_permission(self): + file = models.File.objects.create(**self.file_db_metadata) + new_channel = testdata.channel() + new_channel_exercise = ( + new_channel.main_tree.get_descendants() + .filter(kind_id=content_kinds.EXERCISE) + .first() + ) + new_channel_assessmentitem = new_channel_exercise.assessment_items.first().id + + self.sync_changes( + [generate_update_event(file.id, FILE, {"assessment_item": new_channel_assessmentitem}, channel_id=self.channel.id)], + ) + self.assertNotEqual( + models.File.objects.get(id=file.id).assessment_item, new_channel_assessmentitem, + ) + def test_update_files(self): file1 = models.File.objects.create(**self.file_db_metadata) @@ -260,124 +289,14 @@ def test_update_file(self): {"preset": new_preset}, format="json", ) - self.assertEqual(response.status_code, 200, response.content) - self.assertEqual( - models.File.objects.get(id=file.id).preset_id, new_preset, - ) - - def test_update_file_no_channel(self): - file_metadata = self.file_db_metadata - contentnode_id = file_metadata.pop("contentnode_id") - file = models.File.objects.create(**file_metadata) - - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("file-detail", kwargs={"pk": file.id}), - {"contentnode": contentnode_id}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - self.assertEqual( - models.File.objects.get(id=file.id).contentnode_id, contentnode_id, - ) - - def test_update_file_no_channel_permission(self): - file = models.File.objects.create(**self.file_db_metadata) - new_preset = format_presets.VIDEO_HIGH_RES - - self.channel.editors.remove(self.user) - - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("file-detail", kwargs={"pk": file.id}), - {"preset": new_preset}, - format="json", - ) - self.assertEqual(response.status_code, 404, response.content) - self.assertNotEqual( - models.File.objects.get(id=file.id).preset_id, new_preset, - ) - - def test_update_file_no_channel_edit_permission(self): - file = models.File.objects.create(**self.file_db_metadata) - new_preset = format_presets.VIDEO_HIGH_RES - - self.channel.editors.remove(self.user) - self.channel.viewers.add(self.user) - - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("file-detail", kwargs={"pk": file.id}), - {"preset": new_preset}, - format="json", - ) - self.assertEqual(response.status_code, 404, response.content) - self.assertNotEqual( - models.File.objects.get(id=file.id).preset_id, new_preset, - ) - - def test_update_file_no_node_permission(self): - file = models.File.objects.create(**self.file_db_metadata) - new_channel = testdata.channel() - new_channel_node = new_channel.main_tree.get_descendants().first().id - - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("file-detail", kwargs={"pk": file.id}), - {"contentnode": new_channel_node}, - format="json", - ) - self.assertEqual(response.status_code, 400, response.content) - - def test_update_file_no_assessmentitem_permission(self): - file = models.File.objects.create(**self.file_db_metadata) - new_channel = testdata.channel() - new_channel_exercise = ( - new_channel.main_tree.get_descendants() - .filter(kind_id=content_kinds.EXERCISE) - .first() - ) - new_channel_assessmentitem = new_channel_exercise.assessment_items.first().id - - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("file-detail", kwargs={"pk": file.id}), - {"assessment_item": new_channel_assessmentitem}, - format="json", - ) - self.assertEqual(response.status_code, 400, response.content) - - def test_update_file_empty(self): - - file = models.File.objects.create(**self.file_db_metadata) - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("file-detail", kwargs={"pk": file.id}), {}, format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - - def test_update_file_unwriteable_fields(self): - - file = models.File.objects.create(**self.file_db_metadata) - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("file-detail", kwargs={"pk": file.id}), - {"not_a_field": "not_a_value"}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.status_code, 405, response.content) def test_delete_file(self): file = models.File.objects.create(**self.file_db_metadata) self.client.force_authenticate(user=self.user) response = self.client.delete(reverse("file-detail", kwargs={"pk": file.id})) - self.assertEqual(response.status_code, 204, response.content) - try: - models.File.objects.get(id=file.id) - self.fail("File was not deleted") - except models.File.DoesNotExist: - pass + self.assertEqual(response.status_code, 405, response.content) class UploadFileURLTestCase(StudioAPITestCase): diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py index 1017fa8d9f..ac4afd794f 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py +++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py @@ -73,6 +73,21 @@ def test_create_invitations(self): except models.Invitation.DoesNotExist: self.fail("Invitation 2 was not created") + def test_create_invitation_no_channel_permission(self): + self.client.force_authenticate(user=self.user) + new_channel = testdata.channel() + invitation = self.invitation_metadata + invitation["channel"] = new_channel.id + response = self.sync_changes( + [generate_create_event(invitation["id"], INVITATION, invitation, channel_id=self.channel.id, user_id=self.invited_user.id)], + ) + self.assertEqual(response.status_code, 200, response.content) + try: + models.Invitation.objects.get(id=invitation["id"]) + self.fail("Invitation was created") + except models.Invitation.DoesNotExist: + pass + def test_update_invitation_accept(self): invitation = models.Invitation.objects.create(**self.invitation_db_metadata) @@ -262,31 +277,13 @@ def test_create_invitation(self): response = self.client.post( reverse("invitation-list"), invitation, format="json", ) - self.assertEqual(response.status_code, 201, response.content) - try: - models.Invitation.objects.get(id=invitation["id"]) - except models.Invitation.DoesNotExist: - self.fail("Invitation was not created") - - def test_create_invitation_no_channel_permission(self): - self.client.force_authenticate(user=self.user) - new_channel = testdata.channel() - invitation = self.invitation_metadata - invitation["channel"] = new_channel.id - response = self.client.post( - reverse("invitation-list"), invitation, format="json", - ) - self.assertEqual(response.status_code, 400, response.content) + self.assertEqual(response.status_code, 405, response.content) def test_update_invitation_accept(self): invitation = models.Invitation.objects.create(**self.invitation_db_metadata) self.client.force_authenticate(user=self.invited_user) - response = self.client.patch( - reverse("invitation-detail", kwargs={"pk": invitation.id}), - {"accepted": True}, - format="json", - ) + response = self.client.post(reverse("invitation-accept", kwargs={"pk": invitation.id})) self.assertEqual(response.status_code, 200, response.content) try: models.Invitation.objects.get(id=invitation.id) @@ -301,7 +298,7 @@ def test_update_invitation_accept(self): ) self.assertTrue(models.Change.objects.filter(channel=self.channel).exists()) - def test_update_invitation_decline(self): + def test_update_invitation(self): invitation = models.Invitation.objects.create(**self.invitation_db_metadata) @@ -311,39 +308,7 @@ def test_update_invitation_decline(self): {"declined": True}, format="json", ) - self.assertEqual(response.status_code, 200, response.content) - try: - models.Invitation.objects.get(id=invitation.id) - except models.Invitation.DoesNotExist: - self.fail("Invitation was deleted") - self.assertFalse(self.channel.editors.filter(pk=self.invited_user.id).exists()) - self.assertTrue( - models.Invitation.objects.filter( - email=self.invited_user.email, channel=self.channel - ).exists() - ) - - def test_update_invitation_empty(self): - - invitation = models.Invitation.objects.create(**self.invitation_db_metadata) - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("invitation-detail", kwargs={"pk": invitation.id}), - {}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) - - def test_update_invitation_unwriteable_fields(self): - - invitation = models.Invitation.objects.create(**self.invitation_db_metadata) - self.client.force_authenticate(user=self.user) - response = self.client.patch( - reverse("invitation-detail", kwargs={"pk": invitation.id}), - {"not_a_field": "not_a_value"}, - format="json", - ) - self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.status_code, 405, response.content) def test_delete_invitation(self): invitation = models.Invitation.objects.create(**self.invitation_db_metadata) @@ -352,9 +317,4 @@ def test_delete_invitation(self): response = self.client.delete( reverse("invitation-detail", kwargs={"pk": invitation.id}) ) - self.assertEqual(response.status_code, 204, response.content) - try: - models.Invitation.objects.get(id=invitation.id) - self.fail("Invitation was not deleted") - except models.Invitation.DoesNotExist: - pass + self.assertEqual(response.status_code, 405, response.content) diff --git a/contentcuration/contentcuration/tests/viewsets/test_user.py b/contentcuration/contentcuration/tests/viewsets/test_user.py index 6b38ba1c9c..54c3f98ea5 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_user.py +++ b/contentcuration/contentcuration/tests/viewsets/test_user.py @@ -70,6 +70,14 @@ def test_no_create_user(self): response = self.client.post(reverse("user-list"), user, format="json",) self.assertEqual(response.status_code, 405, response.content) + def test_admin_no_create_user(self): + self.user.is_admin = True + self.user.save() + self.client.force_authenticate(user=self.user) + user = {} + response = self.client.post(reverse("admin-users-list"), user, format="json",) + self.assertEqual(response.status_code, 405, response.content) + def test_no_update_user(self): self.client.force_authenticate(user=self.user) response = self.client.patch( @@ -79,6 +87,19 @@ def test_no_update_user(self): ) self.assertEqual(response.status_code, 405, response.content) + def test_admin_update_user(self): + self.user.is_admin = True + self.user.save() + self.client.force_authenticate(user=self.user) + response = self.client.patch( + reverse("admin-users-detail", kwargs={"pk": self.user.id}), + {"is_active": False}, + format="json", + ) + self.assertEqual(response.status_code, 200, response.content) + self.user.refresh_from_db() + self.assertFalse(self.user.is_active) + def test_no_delete_user(self): self.client.force_authenticate(user=self.user) response = self.client.delete( @@ -86,6 +107,17 @@ def test_no_delete_user(self): ) self.assertEqual(response.status_code, 405, response.content) + def test_admin_delete_user(self): + self.user.is_admin = True + self.user.save() + self.client.force_authenticate(user=self.user) + response = self.client.delete( + reverse("admin-users-detail", kwargs={"pk": self.user.id}) + ) + self.assertEqual(response.status_code, 204, response.content) + self.user.refresh_from_db() + self.assertTrue(self.user.deleted) + class ChannelUserCRUDTestCase(StudioAPITestCase): def setUp(self): diff --git a/contentcuration/contentcuration/utils/db_tools.py b/contentcuration/contentcuration/utils/db_tools.py index aa4e1b43d1..820f9ba1a2 100644 --- a/contentcuration/contentcuration/utils/db_tools.py +++ b/contentcuration/contentcuration/utils/db_tools.py @@ -67,7 +67,7 @@ def create_channel( domain = uuid.uuid5(uuid.NAMESPACE_DNS, name) node_id = uuid.uuid5(domain, name) - channel, _new = Channel.objects.get_or_create(pk=node_id.hex) + channel, _new = Channel.objects.get_or_create(actor_id=editors[0].id, pk=node_id.hex) channel.name = name channel.description = description @@ -86,7 +86,6 @@ def create_channel( channel.viewers.add(v) channel.save() - channel.mark_created(editors[0]) channel.main_tree.get_descendants().delete() channel.staging_tree and channel.staging_tree.get_descendants().delete() return channel diff --git a/contentcuration/contentcuration/utils/import_tools.py b/contentcuration/contentcuration/utils/import_tools.py index d65f6464c9..532adc4a52 100644 --- a/contentcuration/contentcuration/utils/import_tools.py +++ b/contentcuration/contentcuration/utils/import_tools.py @@ -99,10 +99,10 @@ def import_channel(source_id, target_id=None, download_url=None, editor=None, lo # Start by creating channel log.info("Creating channel...") - channel, root_pk = create_channel(conn, target_id) - if editor: - channel.editors.add(models.User.objects.get(email=editor)) - channel.save() + editor = models.User.objects.get(email=editor) + channel, root_pk = create_channel(conn, target_id, editor) + channel.editors.add(editor) + channel.save() # Create root node root = models.ContentNode.objects.create( @@ -140,7 +140,7 @@ def import_channel(source_id, target_id=None, download_url=None, editor=None, lo log.info("\n\n********** IMPORT COMPLETE **********\n\n") -def create_channel(cursor, target_id): +def create_channel(cursor, target_id, editor): """ create_channel: Create channel at target id Args: cursor (sqlite3.Connection): connection to export database @@ -150,7 +150,7 @@ def create_channel(cursor, target_id): id, name, description, thumbnail, root_pk, version, last_updated = cursor.execute( 'SELECT id, name, description, thumbnail, root_pk, version, last_updated FROM {table}' .format(table=CHANNEL_TABLE)).fetchone() - channel, is_new = models.Channel.objects.get_or_create(pk=target_id) + channel, is_new = models.Channel.objects.get_or_create(pk=target_id, actor_id=editor.id) channel.name = name channel.description = description channel.thumbnail = write_to_thumbnail_file(thumbnail) diff --git a/contentcuration/contentcuration/views/base.py b/contentcuration/contentcuration/views/base.py index 9b7ea8e3d9..8ce3966e0c 100644 --- a/contentcuration/contentcuration/views/base.py +++ b/contentcuration/contentcuration/views/base.py @@ -130,7 +130,7 @@ def get_prober_channel(request): channel = Channel.objects.filter(editors=request.user).first() if not channel: - channel = Channel.objects.create(name="Prober channel", editors=[request.user]) + channel = Channel.objects.create(actor_id=request.user.id, name="Prober channel", editors=[request.user]) return Response(SimplifiedChannelProbeCheckSerializer(channel).data) diff --git a/contentcuration/contentcuration/views/internal.py b/contentcuration/contentcuration/views/internal.py index 5767928719..da3fd501fd 100644 --- a/contentcuration/contentcuration/views/internal.py +++ b/contentcuration/contentcuration/views/internal.py @@ -2,8 +2,8 @@ import logging from builtins import str from collections import namedtuple -from distutils.version import LooseVersion +from distutils.version import LooseVersion from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import PermissionDenied from django.core.exceptions import SuspiciousOperation @@ -470,7 +470,7 @@ def get_status(channel_id): def create_channel(channel_data, user): """ Set up channel """ # Set up initial channel - channel, isNew = Channel.objects.get_or_create(id=channel_data["id"]) + channel, isNew = Channel.objects.get_or_create(id=channel_data["id"], actor_id=user.id) # Add user as editor if channel is new or channel has no editors # Otherwise, check if user is an editor @@ -519,7 +519,6 @@ def create_channel(channel_data, user): map_files_to_node(user, channel.chef_tree, files) channel.chef_tree.save() channel.save() - channel.mark_created(user) # Delete chef tree if it already exists if old_chef_tree and old_chef_tree != channel.staging_tree: diff --git a/contentcuration/contentcuration/viewsets/base.py b/contentcuration/contentcuration/viewsets/base.py index 5b964ac7de..38eb177064 100644 --- a/contentcuration/contentcuration/viewsets/base.py +++ b/contentcuration/contentcuration/viewsets/base.py @@ -156,11 +156,10 @@ def update(self, instance, validated_data): raise ValueError("Many to many fields must be explicitly handled", attr) setattr(instance, attr, value) - if hasattr(instance, "on_update") and callable(instance.on_update): - instance.on_update() - if not getattr(self, "parent"): instance.save() + elif hasattr(instance, "on_update") and callable(instance.on_update): + instance.on_update() return instance @@ -191,11 +190,10 @@ def create(self, validated_data): instance = ModelClass(**validated_data) - if hasattr(instance, "on_create") and callable(instance.on_create): - instance.on_create() - if not getattr(self, "parent", False): instance.save() + elif hasattr(instance, "on_create") and callable(instance.on_create): + instance.on_create() return instance @@ -707,6 +705,8 @@ def create_from_changes(self, changes): return errors + +class RESTCreateModelMixin(CreateModelMixin): def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -728,11 +728,6 @@ class DestroyModelMixin(object): def _map_delete_change(self, change): return change["key"] - def destroy(self, request, *args, **kwargs): - instance = self.get_edit_object() - self.perform_destroy(instance) - return Response(status=HTTP_204_NO_CONTENT) - def perform_destroy(self, instance): instance.delete() @@ -755,6 +750,13 @@ def delete_from_changes(self, changes): return errors +class RESTDestroyModelMixin(DestroyModelMixin): + def destroy(self, request, *args, **kwargs): + instance = self.get_edit_object() + self.perform_destroy(instance) + return Response(status=HTTP_204_NO_CONTENT) + + class UpdateModelMixin(object): def _map_update_change(self, change): return dict( @@ -792,6 +794,8 @@ def update_from_changes(self, changes): errors.append(change) return errors + +class RESTUpdateModelMixin(UpdateModelMixin): def update(self, request, *args, **kwargs): partial = kwargs.pop("partial", False) instance = self.get_edit_object() diff --git a/contentcuration/contentcuration/viewsets/channel.py b/contentcuration/contentcuration/viewsets/channel.py index 6715cfd3e4..fdd9c99cd8 100644 --- a/contentcuration/contentcuration/viewsets/channel.py +++ b/contentcuration/contentcuration/viewsets/channel.py @@ -21,9 +21,12 @@ from rest_framework.exceptions import ValidationError from rest_framework.permissions import AllowAny from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from rest_framework.serializers import CharField from rest_framework.serializers import FloatField from rest_framework.serializers import IntegerField +from rest_framework.status import HTTP_201_CREATED +from rest_framework.status import HTTP_204_NO_CONTENT from search.models import ChannelFullTextSearch from search.models import ContentNodeFullTextSearch from search.utils import get_fts_search_query @@ -48,6 +51,8 @@ from contentcuration.viewsets.base import create_change_tracker from contentcuration.viewsets.base import ReadOnlyValuesViewset from contentcuration.viewsets.base import RequiredFilterSet +from contentcuration.viewsets.base import RESTDestroyModelMixin +from contentcuration.viewsets.base import RESTUpdateModelMixin from contentcuration.viewsets.base import ValuesViewset from contentcuration.viewsets.common import ContentDefaultsSerializer from contentcuration.viewsets.common import JSONFieldDictSerializer @@ -56,6 +61,7 @@ from contentcuration.viewsets.common import UUIDInFilter from contentcuration.viewsets.sync.constants import CHANNEL from contentcuration.viewsets.sync.constants import PUBLISHED +from contentcuration.viewsets.sync.utils import generate_create_event from contentcuration.viewsets.sync.utils import generate_update_event from contentcuration.viewsets.sync.utils import log_sync_exception from contentcuration.viewsets.user import IsAdminUser @@ -271,10 +277,12 @@ def create(self, validated_data): validated_data["content_defaults"] = self.fields["content_defaults"].create( content_defaults ) - instance = super(ChannelSerializer, self).create(validated_data) + instance = Channel(**validated_data) + user = None if "request" in self.context: user = self.context["request"].user - instance.mark_created(user) + instance.save(actor_id=user.id) + if user: try: # Wrap in try catch, fix for #3049 # This has been newly created so add the current user as an editor @@ -299,7 +307,6 @@ def create(self, validated_data): def update(self, instance, validated_data): content_defaults = validated_data.pop("content_defaults", None) - is_deleted = validated_data.get("deleted") if content_defaults is not None: validated_data["content_defaults"] = self.fields["content_defaults"].update( instance.content_defaults, content_defaults @@ -309,14 +316,9 @@ def update(self, instance, validated_data): if "request" in self.context: user_id = self.context["request"].user.id - was_deleted = instance.deleted - instance = super(ChannelSerializer, self).update(instance, validated_data) - # mark the instance as deleted or recovered, if requested - if user_id is not None and is_deleted is not None and is_deleted != was_deleted: - if is_deleted: - instance.mark_deleted(user_id) - else: - instance.mark_recovered(user_id) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save(actor_id=user_id) return instance @@ -413,9 +415,32 @@ class ChannelViewSet(ValuesViewset): field_map = channel_field_map values = base_channel_values + ("edit", "view", "unpublished_changes") + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + self.perform_create(serializer) + + except IntegrityError as e: + return Response({"error": str(e)}, status=409) + instance = serializer.instance + Change.create_change(generate_create_event(instance.id, CHANNEL, request.data, channel_id=instance.id), applied=True, created_by_id=request.user.id) + return Response(self.serialize_object(pk=instance.pk), status=HTTP_201_CREATED) + + def destroy(self, request, *args, **kwargs): + instance = self.get_edit_object() + self.perform_destroy(instance) + Change.create_change( + generate_update_event( + instance.id, CHANNEL, {"deleted": True}, channel_id=instance.id + ), applied=True, created_by_id=request.user.id + ) + return Response(status=HTTP_204_NO_CONTENT) + def perform_destroy(self, instance): instance.deleted = True - instance.save(update_fields=["deleted"]) + instance.save(update_fields=["deleted"], actor_id=self.request.user.id) def get_queryset(self): queryset = super(ChannelViewSet, self).get_queryset() @@ -714,7 +739,7 @@ class Meta: nested_writes = True -class AdminChannelViewSet(ChannelViewSet): +class AdminChannelViewSet(ChannelViewSet, RESTUpdateModelMixin, RESTDestroyModelMixin): pagination_class = CatalogListPagination permission_classes = [IsAdminUser] serializer_class = AdminChannelSerializer @@ -746,8 +771,22 @@ class AdminChannelViewSet(ChannelViewSet): ) def perform_destroy(self, instance): + # Note that we deliberately do not create a delete event for the channel + # as because it will have no channel to refer to in its foreign key, it + # will never propagated back to the client. instance.delete() + def update(self, request, *args, **kwargs): + partial = kwargs.pop("partial", False) + instance = self.get_edit_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + Change.create_change(generate_update_event(instance.id, CHANNEL, request.data, channel_id=instance.id), applied=True, created_by_id=request.user.id) + + return Response(self.serialize_object()) + def get_queryset(self): channel_main_tree_nodes = ContentNode.objects.filter( tree_id=OuterRef("main_tree__tree_id") diff --git a/contentcuration/contentcuration/viewsets/channelset.py b/contentcuration/contentcuration/viewsets/channelset.py index 7ad1fa6723..90afb1ed05 100644 --- a/contentcuration/contentcuration/viewsets/channelset.py +++ b/contentcuration/contentcuration/viewsets/channelset.py @@ -11,6 +11,7 @@ from contentcuration.viewsets.base import BulkListSerializer from contentcuration.viewsets.base import BulkModelSerializer from contentcuration.viewsets.base import FilterSet +from contentcuration.viewsets.base import RESTCreateModelMixin from contentcuration.viewsets.base import ValuesViewset from contentcuration.viewsets.common import NotNullMapArrayAgg from contentcuration.viewsets.common import UserFilteredPrimaryKeyRelatedField @@ -65,7 +66,7 @@ class Meta: fields = ("edit",) -class ChannelSetViewSet(ValuesViewset): +class ChannelSetViewSet(ValuesViewset, RESTCreateModelMixin): queryset = ChannelSet.objects.all() serializer_class = ChannelSetSerializer filter_class = ChannelSetFilter diff --git a/contentcuration/contentcuration/viewsets/invitation.py b/contentcuration/contentcuration/viewsets/invitation.py index 3a7f4794fd..56cd27ddd8 100644 --- a/contentcuration/contentcuration/viewsets/invitation.py +++ b/contentcuration/contentcuration/viewsets/invitation.py @@ -1,9 +1,11 @@ from django_filters.rest_framework import CharFilter from django_filters.rest_framework import FilterSet from rest_framework import serializers +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from contentcuration.models import Change from contentcuration.models import Channel from contentcuration.models import Invitation from contentcuration.viewsets.base import BulkListSerializer @@ -135,11 +137,13 @@ def perform_update(self, serializer): instance = serializer.save() instance.save() - def update(self, request, *args, **kwargs): - partial = kwargs.pop("partial", False) - instance = self.get_edit_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - - return Response(self.serialize_object(id=instance.id)) + @action(detail=True, methods=["post"]) + def accept(self, request, pk=None): + invitation = self.get_object() + invitation.accept() + Change.create_change( + generate_update_event( + invitation.id, INVITATION, {"accepted": True}, channel_id=invitation.channel_id + ), applied=True, created_by_id=request.user.id + ) + return Response({"status": "success"}) diff --git a/contentcuration/contentcuration/viewsets/user.py b/contentcuration/contentcuration/viewsets/user.py index a204717454..766e3097a0 100644 --- a/contentcuration/contentcuration/viewsets/user.py +++ b/contentcuration/contentcuration/viewsets/user.py @@ -29,7 +29,8 @@ from contentcuration.viewsets.base import BulkModelSerializer from contentcuration.viewsets.base import ReadOnlyValuesViewset from contentcuration.viewsets.base import RequiredFilterSet -from contentcuration.viewsets.base import ValuesViewset +from contentcuration.viewsets.base import RESTDestroyModelMixin +from contentcuration.viewsets.base import RESTUpdateModelMixin from contentcuration.viewsets.common import NotNullArrayAgg from contentcuration.viewsets.common import SQCount from contentcuration.viewsets.common import UUIDFilter @@ -346,7 +347,7 @@ class Meta: list_serializer_class = BulkListSerializer -class AdminUserViewSet(ValuesViewset): +class AdminUserViewSet(ReadOnlyValuesViewset, RESTUpdateModelMixin, RESTDestroyModelMixin): pagination_class = UserListPagination permission_classes = [IsAdminUser] serializer_class = AdminUserSerializer diff --git a/contentcuration/kolibri_public/tests/test_mapper.py b/contentcuration/kolibri_public/tests/test_mapper.py index 4b56813464..918a352c19 100644 --- a/contentcuration/kolibri_public/tests/test_mapper.py +++ b/contentcuration/kolibri_public/tests/test_mapper.py @@ -14,6 +14,7 @@ from le_utils.constants import content_kinds from contentcuration.models import Channel +from contentcuration.tests.testdata import user class ChannelMapperTest(TestCase): @@ -35,6 +36,7 @@ def setUpClass(cls): super(ChannelMapperTest, cls).setUpClass() call_command("loadconstants") _, cls.tempdb = tempfile.mkstemp(suffix=".sqlite3") + admin_user = user() with using_content_database(cls.tempdb): call_command("migrate", "content", database=get_active_content_database(), no_input=True) @@ -45,7 +47,7 @@ def setUpClass(cls): builder.insert_into_default_db() cls.source_root = kolibri_content_models.ContentNode.objects.get(id=builder.root_node["id"]) cls.channel = kolibri_content_models.ChannelMetadata.objects.get(id=builder.channel["id"]) - contentcuration_channel = Channel.objects.create(id=cls.channel.id, name=cls.channel.name, public=True) + contentcuration_channel = Channel.objects.create(actor_id=admin_user.id, id=cls.channel.id, name=cls.channel.name, public=True) contentcuration_channel.main_tree.published = True contentcuration_channel.main_tree.save() cls.mapper = ChannelMapper(cls.channel) diff --git a/contentcuration/search/tests/test_search.py b/contentcuration/search/tests/test_search.py index 8489ece577..aef996296d 100644 --- a/contentcuration/search/tests/test_search.py +++ b/contentcuration/search/tests/test_search.py @@ -51,7 +51,7 @@ def test_search(self): user = testdata.user(email="a{}@a.com".format(i)) users.append(user) - channel = Channel.objects.create(name="user_a{}_channel".format(i)) + channel = Channel.objects.create(actor_id=user.id, name="user_a{}_channel".format(i)) channel.save() channels.append(channel) channel.editors.add(user)