diff --git a/contentcuration/contentcuration/api.py b/contentcuration/contentcuration/api.py index 1cd44d9098..33c9692cbc 100644 --- a/contentcuration/contentcuration/api.py +++ b/contentcuration/contentcuration/api.py @@ -97,4 +97,4 @@ def activate_channel(channel, user): "staging_root_id": None }, channel_id=channel.id, - ), applied=True) + ), applied=True, created_by_id=user.id) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue index b53ab118fe..c25dd7ce60 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/CompletionOptions.vue @@ -421,8 +421,8 @@ }, }; } - this.m = this.value.threshold.m || this.m; - this.n = this.value.threshold.n || this.n; + this.m = (this.value.threshold ? this.value.threshold.m : null) || this.m; + this.n = (this.value.threshold ? this.value.threshold.n : null) || this.n; this.handleInput(update); }, }, @@ -450,6 +450,10 @@ }, masteryModelItem: { get() { + if (!this.value.threshold) { + return { m: null, n: null }; + } + if (this.value.threshold.mastery_model !== MasteryModelsNames.M_OF_N) { return { m: this.value.threshold.m, diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue index 503b873b96..4625aaf866 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/edit/EditModal.vue @@ -248,7 +248,7 @@ computed: { ...mapGetters('contentNode', ['getContentNode', 'getContentNodeIsValid']), ...mapGetters('assessmentItem', ['getAssessmentItems']), - ...mapGetters('currentChannel', ['canEdit']), + ...mapGetters('currentChannel', ['currentChannel', 'canEdit']), ...mapGetters('file', ['contentNodesAreUploading', 'getContentNodeFiles']), ...mapState({ online: state => state.connection.online, @@ -448,6 +448,7 @@ return this.createContentNode({ kind, parent: this.$route.params.nodeId, + channel_id: this.currentChannel.id, ...payload, }).then(newNodeId => { this.$router.push({ diff --git a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/__tests__/actions.spec.js b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/__tests__/actions.spec.js index 7b329b5c7d..0ebffd904c 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/__tests__/actions.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/__tests__/actions.spec.js @@ -16,7 +16,7 @@ describe('contentNode actions', () => { let id; const contentNodeDatum = { title: 'test', parent: parentId, lft: 1, tags: {} }; beforeEach(() => { - return ContentNode.put(contentNodeDatum).then(newId => { + return ContentNode._put(contentNodeDatum).then(newId => { id = newId; contentNodeDatum.id = newId; jest @@ -25,7 +25,7 @@ describe('contentNode actions', () => { jest .spyOn(ContentNode, 'fetchModel') .mockImplementation(() => Promise.resolve(contentNodeDatum)); - return ContentNode.put({ title: 'notatest', parent: newId, lft: 2 }).then(() => { + return ContentNode._put({ title: 'notatest', parent: newId, lft: 2 }).then(() => { store = storeFactory({ modules: { assessmentItem, diff --git a/contentcuration/contentcuration/frontend/shared/data/__tests__/ContentNodeResource.spec.js b/contentcuration/contentcuration/frontend/shared/data/__tests__/ContentNodeResource.spec.js index 8a8f5984aa..810da71ed0 100644 --- a/contentcuration/contentcuration/frontend/shared/data/__tests__/ContentNodeResource.spec.js +++ b/contentcuration/contentcuration/frontend/shared/data/__tests__/ContentNodeResource.spec.js @@ -183,7 +183,10 @@ describe('ContentNode methods', () => { }); function mockMethod(name, implementation) { - const mock = jest.spyOn(ContentNode, name).mockImplementation(implementation); + const path = name.split('.'); + const prop = path.pop(); + const mockObj = path.reduce((mockObj, prop) => mockObj[prop], ContentNode); + const mock = jest.spyOn(mockObj, prop).mockImplementation(implementation); mocks.push(mock); return mock; } @@ -270,6 +273,7 @@ describe('ContentNode methods', () => { resolveParent, treeLock, get, + tableGet, where, getNewSortOrder; beforeEach(() => { @@ -284,6 +288,7 @@ describe('ContentNode methods', () => { treeLock = mockMethod('treeLock', (id, cb) => cb()); getNewSortOrder = mockMethod('getNewSortOrder', () => lft); get = mockMethod('get', () => Promise.resolve(node)); + tableGet = mockMethod('table.get', () => Promise.resolve()); where = mockMethod('where', () => Promise.resolve(siblings)); }); @@ -395,13 +400,13 @@ describe('ContentNode methods', () => { ).resolves.toEqual('results'); expect(resolveParent).toHaveBeenCalledWith('target', 'position'); expect(treeLock).toHaveBeenCalledWith(parent.root_id, expect.any(Function)); - expect(get).toHaveBeenCalledWith('abc123', false); + expect(tableGet).toHaveBeenCalledWith('abc123'); expect(where).toHaveBeenCalledWith({ parent: parent.id }, false); expect(getNewSortOrder).not.toBeCalled(); expect(cb).toBeCalled(); const result = cb.mock.calls[0][0]; expect(result).toMatchObject({ - node, + node: undefined, parent, payload: { id: expect.not.stringMatching('abc123'), @@ -434,13 +439,13 @@ describe('ContentNode methods', () => { ).resolves.toEqual('results'); expect(resolveParent).toHaveBeenCalledWith('target', 'position'); expect(treeLock).toHaveBeenCalledWith(parent.root_id, expect.any(Function)); - expect(get).toHaveBeenCalledWith('abc123', false); + expect(tableGet).toHaveBeenCalledWith('abc123'); expect(where).toHaveBeenCalledWith({ parent: parent.id }, false); expect(getNewSortOrder).toHaveBeenCalledWith(null, 'target', 'position', siblings); expect(cb).toBeCalled(); const result = cb.mock.calls[0][0]; expect(result).toMatchObject({ - node, + node: undefined, parent, payload: { id: expect.not.stringMatching('abc123'), @@ -473,7 +478,7 @@ describe('ContentNode methods', () => { ).rejects.toThrow('New lft value evaluated to null'); expect(resolveParent).toHaveBeenCalledWith('target', 'position'); expect(treeLock).toHaveBeenCalledWith(parent.root_id, expect.any(Function)); - expect(get).toHaveBeenCalledWith('abc123', false); + expect(tableGet).toHaveBeenCalledWith('abc123'); expect(where).toHaveBeenCalledWith({ parent: parent.id }, false); expect(getNewSortOrder).toHaveBeenCalledWith(null, 'target', 'position', siblings); expect(cb).not.toBeCalled(); diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index cd0225f2d6..4503a0c94d 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1245,8 +1245,11 @@ export const ContentNode = new TreeResource({ // Using root_id, we'll keep this locked while we handle this, so no other operations // happen while we're potentially waiting for some data we need (siblings, source node) return this.treeLock(parent.root_id, () => { + // Don't trigger fetch, if this is specified as a creation + const getNode = isCreate ? this.table.get(id) : this.get(id, false); + // Preload the ID we're referencing, and get siblings to determine sort order - return Promise.all([this.get(id, false), this.where({ parent: parent.id }, false)]).then( + return Promise.all([getNode, this.where({ parent: parent.id }, false)]).then( ([node, siblings]) => { let lft = 1; if (siblings.length) { @@ -1266,6 +1269,7 @@ export const ContentNode = new TreeResource({ const payload = { id: isCreate ? uuid4() : id, parent: parent.id, + root_id: parent.root_id, lft, changed: true, }; @@ -1295,6 +1299,29 @@ export const ContentNode = new TreeResource({ }); }, + // Retain super's put method that does not handle tree insertion + _put: TreeResource.prototype.put, + + /** + * @param {Object} obj + * @return {Promise} + */ + put(obj) { + const prepared = this._preparePut(obj); + + return this.resolveTreeInsert( + prepared.id, + prepared.parent, + RELATIVE_TREE_POSITIONS.LAST_CHILD, + true, + data => { + return this.transaction({ mode: 'rw' }, () => { + return this.table.put({ ...prepared, ...data.payload }); + }); + } + ); + }, + move(id, target, position = RELATIVE_TREE_POSITIONS.FIRST_CHILD) { return this.resolveTreeInsert(id, target, position, false, data => { // Ignore changes from this operation except for the explicit move change we generate. diff --git a/contentcuration/contentcuration/frontend/shared/utils/validation.js b/contentcuration/contentcuration/frontend/shared/utils/validation.js index 37a9f16fa0..4ea08515f7 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/validation.js +++ b/contentcuration/contentcuration/frontend/shared/utils/validation.js @@ -1,4 +1,5 @@ import get from 'lodash/get'; +import CompletionCriteriaModels from 'kolibri-constants/CompletionCriteria'; import translator from '../translator'; import { AssessmentItemTypes, ValidationErrors } from '../constants'; import Licenses from 'shared/leUtils/Licenses'; @@ -52,6 +53,9 @@ export function isNodeComplete({ nodeDetails, assessmentItems, files }) { } if (getNodeDetailsErrors(nodeDetails).length) { + if (process.env.NODE_ENV !== 'production') { + console.info('Node is incomplete', getNodeDetailsErrors(nodeDetails)); + } return false; } if ( @@ -59,17 +63,26 @@ export function isNodeComplete({ nodeDetails, assessmentItems, files }) { nodeDetails.kind !== ContentKindsNames.EXERCISE ) { if (getNodeFilesErrors(files).length) { + if (process.env.NODE_ENV !== 'production') { + console.info("Node's files are incomplete", getNodeFilesErrors(files)); + } return false; } } if (nodeDetails.kind !== ContentKindsNames.TOPIC) { const completionCriteria = get(nodeDetails, 'extra_fields.options.completion_criteria'); if (completionCriteria && !validateCompletionCriteria(completionCriteria, nodeDetails.kind)) { + if (process.env.NODE_ENV !== 'production') { + console.info("Node's completion criteria is invalid", validateCompletionCriteria.errors); + } return false; } } if (nodeDetails.kind === ContentKindsNames.EXERCISE) { if (!assessmentItems.length) { + if (process.env.NODE_ENV !== 'production') { + console.info('Exercise node is missing assessment items'); + } return false; } @@ -78,6 +91,12 @@ export function isNodeComplete({ nodeDetails, assessmentItems, files }) { return getAssessmentItemErrors(sanitizedAssessmentItem).length; }; if (assessmentItems.some(isInvalid)) { + if (process.env.NODE_ENV !== 'production') { + console.info( + "Exercise node's assessment items are invalid", + assessmentItems.some(isInvalid) + ); + } return false; } } @@ -91,7 +110,11 @@ function _getLicense(node) { } function _getMasteryModel(node) { - return node.extra_fields; + const criteria = get(node, 'extra_fields.options.completion_criteria', {}); + if (criteria.model === CompletionCriteriaModels.MASTERY) { + return criteria.threshold || {}; + } + return {}; } function _getLearningActivity(node) { diff --git a/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js b/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js index 9c2d4dbe24..4bf4d10887 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js +++ b/contentcuration/contentcuration/frontend/shared/utils/validation.spec.js @@ -23,6 +23,17 @@ import { import { MasteryModelsNames } from 'shared/leUtils/MasteryModels'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; +function generateMasteryExtraFields(mastery) { + return { + options: { + completion_criteria: { + model: CompletionCriteriaModels.MASTERY, + threshold: mastery, + }, + }, + }; +} + describe('channelEdit utils', () => { describe('translateValidator', () => { it('returns true if a validator function returns true for a value', () => { @@ -200,7 +211,9 @@ describe('channelEdit utils', () => { }); it('returns no errors when a mastery model specified', () => { - const node = { extra_fields: { mastery_model: MasteryModelsNames.DO_ALL } }; + const node = { + extra_fields: generateMasteryExtraFields({ mastery_model: MasteryModelsNames.DO_ALL }), + }; expect(getNodeMasteryModelErrors(node)).toEqual([]); }); }); @@ -208,25 +221,32 @@ describe('channelEdit utils', () => { describe('getNodeMasteryModelMErrors', () => { it(`returns no errors for empty m value when no mastery model is specified`, () => { - const node = { extra_fields: { mastery_model: null, m: null } }; + const node = { extra_fields: generateMasteryExtraFields({ mastery_model: null, m: null }) }; expect(getNodeMasteryModelMErrors(node)).toEqual([]); }); it(`returns no errors for empty m value for mastery models other than m of n`, () => { - const node = { extra_fields: { mastery_model: MasteryModelsNames.DO_ALL, m: null } }; + const node = { + extra_fields: generateMasteryExtraFields({ + mastery_model: MasteryModelsNames.DO_ALL, + m: null, + }), + }; expect(getNodeMasteryModelMErrors(node)).toEqual([]); }); describe('for a mastery model m of n', () => { let node; beforeEach(() => { - node = { extra_fields: { mastery_model: MasteryModelsNames.M_OF_N } }; + node = { + extra_fields: generateMasteryExtraFields({ mastery_model: MasteryModelsNames.M_OF_N }), + }; }); it('returns errors for empty m value', () => { - node.extra_fields.m = undefined; - node.extra_fields.n = 3; + node.extra_fields.options.completion_criteria.threshold.m = undefined; + node.extra_fields.options.completion_criteria.threshold.n = 3; expect(getNodeMasteryModelMErrors(node)).toEqual([ ValidationErrors.MASTERY_MODEL_M_REQUIRED, @@ -237,8 +257,8 @@ describe('channelEdit utils', () => { }); it('returns an error for non-integer m value', () => { - node.extra_fields.m = 1.27; - node.extra_fields.n = 3; + node.extra_fields.options.completion_criteria.threshold.m = 1.27; + node.extra_fields.options.completion_criteria.threshold.n = 3; expect(getNodeMasteryModelMErrors(node)).toEqual([ ValidationErrors.MASTERY_MODEL_M_WHOLE_NUMBER, @@ -246,8 +266,8 @@ describe('channelEdit utils', () => { }); it('returns an error for m value smaller than zero', () => { - node.extra_fields.m = -2; - node.extra_fields.n = 3; + node.extra_fields.options.completion_criteria.threshold.m = -2; + node.extra_fields.options.completion_criteria.threshold.n = 3; expect(getNodeMasteryModelMErrors(node)).toEqual([ ValidationErrors.MASTERY_MODEL_M_GT_ZERO, @@ -255,15 +275,15 @@ describe('channelEdit utils', () => { }); it('returns an error for m value larger than n value', () => { - node.extra_fields.m = 4; - node.extra_fields.n = 3; + node.extra_fields.options.completion_criteria.threshold.m = 4; + node.extra_fields.options.completion_criteria.threshold.n = 3; expect(getNodeMasteryModelMErrors(node)).toEqual([ValidationErrors.MASTERY_MODEL_M_LTE_N]); }); it('returns no errors for m whole number smaller than n value', () => { - node.extra_fields.m = 3; - node.extra_fields.n = 4; + node.extra_fields.options.completion_criteria.threshold.m = 3; + node.extra_fields.options.completion_criteria.threshold.n = 4; expect(getNodeMasteryModelMErrors(node)).toEqual([]); }); @@ -273,24 +293,31 @@ describe('channelEdit utils', () => { describe('getNodeMasteryModelNErrors', () => { it(`returns no errors for empty n value when no mastery model is specified`, () => { - const node = { extra_fields: { mastery_model: null, n: null } }; + const node = { extra_fields: generateMasteryExtraFields({ mastery_model: null, n: null }) }; expect(getNodeMasteryModelNErrors(node)).toEqual([]); }); it(`returns no errors for empty n value for mastery models other than m of n`, () => { - const node = { extra_fields: { mastery_model: MasteryModelsNames.DO_ALL, n: null } }; + const node = { + extra_fields: generateMasteryExtraFields({ + mastery_model: MasteryModelsNames.DO_ALL, + n: null, + }), + }; expect(getNodeMasteryModelNErrors(node)).toEqual([]); }); describe('for a mastery model m of n', () => { let node; beforeEach(() => { - node = { extra_fields: { mastery_model: MasteryModelsNames.M_OF_N } }; + node = { + extra_fields: generateMasteryExtraFields({ mastery_model: MasteryModelsNames.M_OF_N }), + }; }); it('returns errors for empty n value', () => { - node.extra_fields.n = undefined; + node.extra_fields.options.completion_criteria.threshold.n = undefined; expect(getNodeMasteryModelNErrors(node)).toEqual([ ValidationErrors.MASTERY_MODEL_N_REQUIRED, @@ -300,7 +327,7 @@ describe('channelEdit utils', () => { }); it('returns an error for non-integer n value', () => { - node.extra_fields.n = 1.27; + node.extra_fields.options.completion_criteria.threshold.n = 1.27; expect(getNodeMasteryModelNErrors(node)).toEqual([ ValidationErrors.MASTERY_MODEL_N_WHOLE_NUMBER, @@ -308,7 +335,7 @@ describe('channelEdit utils', () => { }); it('returns an error for n value smaller than zero', () => { - node.extra_fields.n = -2; + node.extra_fields.options.completion_criteria.threshold.n = -2; expect(getNodeMasteryModelNErrors(node)).toEqual([ ValidationErrors.MASTERY_MODEL_N_GT_ZERO, @@ -316,7 +343,7 @@ describe('channelEdit utils', () => { }); it('returns no errors for n whole number', () => { - node.extra_fields.n = 3; + node.extra_fields.options.completion_criteria.threshold.n = 3; expect(getNodeMasteryModelNErrors(node)).toEqual([]); }); @@ -680,9 +707,9 @@ describe('channelEdit utils', () => { kind: 'exercise', license: 8, learning_activities: { test: true }, - extra_fields: { + extra_fields: generateMasteryExtraFields({ mastery_model: 'do_all', - }, + }), }, [], ], @@ -692,10 +719,10 @@ describe('channelEdit utils', () => { kind: 'exercise', license: 8, learning_activities: { test: true }, - extra_fields: { + extra_fields: generateMasteryExtraFields({ mastery_model: 'm_of_n', m: 3, - }, + }), }, [ ValidationErrors.MASTERY_MODEL_M_LTE_N, @@ -710,11 +737,11 @@ describe('channelEdit utils', () => { kind: 'exercise', license: 8, learning_activities: { test: true }, - extra_fields: { + extra_fields: generateMasteryExtraFields({ mastery_model: 'm_of_n', m: 3, n: 2, - }, + }), }, [ValidationErrors.MASTERY_MODEL_M_LTE_N], ], @@ -724,11 +751,11 @@ describe('channelEdit utils', () => { kind: 'exercise', license: 8, learning_activities: { test: true }, - extra_fields: { + extra_fields: generateMasteryExtraFields({ mastery_model: 'm_of_n', m: 2, n: 3, - }, + }), }, [], ], diff --git a/contentcuration/contentcuration/tests/utils/test_user.py b/contentcuration/contentcuration/tests/utils/test_user.py new file mode 100644 index 0000000000..391e4a98ba --- /dev/null +++ b/contentcuration/contentcuration/tests/utils/test_user.py @@ -0,0 +1,18 @@ +import mock + +from contentcuration.tests.base import StudioTestCase +from contentcuration.utils.user import calculate_user_storage +from contentcuration.utils.user import delay_user_storage_calculation + + +class UserUtilsTestCase(StudioTestCase): + @mock.patch("contentcuration.utils.user.calculate_user_storage_task") + def test_delay_storage_calculation(self, mock_task): + @delay_user_storage_calculation + def do_test(): + calculate_user_storage(self.admin_user.id) + calculate_user_storage(self.admin_user.id) + mock_task.fetch_or_enqueue.assert_not_called() + + do_test() + mock_task.fetch_or_enqueue.assert_called_once_with(self.admin_user, user_id=self.admin_user.id) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 2dacc9e5a4..644bd77795 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -47,6 +47,7 @@ from contentcuration.utils.parser import extract_value from contentcuration.utils.parser import load_json_string from contentcuration.utils.sentry import report_exception +from contentcuration.utils.user import delay_user_storage_calculation logmodule.basicConfig() @@ -779,6 +780,7 @@ def fill_published_fields(channel, version_notes): channel.save() +@delay_user_storage_calculation def publish_channel( user_id, channel_id, diff --git a/contentcuration/contentcuration/utils/user.py b/contentcuration/contentcuration/utils/user.py index d76f6ab917..fea59792d1 100644 --- a/contentcuration/contentcuration/utils/user.py +++ b/contentcuration/contentcuration/utils/user.py @@ -1,11 +1,44 @@ import logging +from contextlib import ContextDecorator from contentcuration.tasks import calculate_user_storage_task +class DelayUserStorageCalculation(ContextDecorator): + """ + Decorator class that will dedupe and delay requests to enqueue storage calculation tasks for users + until after the wrapped function has exited + """ + depth = 0 + queue = [] + + @property + def is_active(self): + return self.depth > 0 + + def __enter__(self): + self.depth += 1 + + def __exit__(self, exc_type, exc_val, exc_tb): + self.depth -= 1 + if not self.is_active: + user_ids = set(self.queue) + self.queue = [] + for user_id in user_ids: + calculate_user_storage(user_id) + + +delay_user_storage_calculation = DelayUserStorageCalculation() + + def calculate_user_storage(user_id): """TODO: Perhaps move this to User model to avoid unnecessary User lookups""" from contentcuration.models import User + + if delay_user_storage_calculation.is_active: + delay_user_storage_calculation.queue.append(user_id) + return + try: if user_id is None: raise User.DoesNotExist diff --git a/contentcuration/contentcuration/views/internal.py b/contentcuration/contentcuration/views/internal.py index deb2efcdcb..8f411a553b 100644 --- a/contentcuration/contentcuration/views/internal.py +++ b/contentcuration/contentcuration/views/internal.py @@ -237,7 +237,7 @@ def api_commit_channel(request): channel_id=channel_id, ) # Send event (new staging tree or new main tree) for the channel - Change.create_change(event) + Change.create_change(event, created_by_id=request.user.id) # Mark old staging tree for garbage collection if old_staging and old_staging != obj.main_tree: diff --git a/contentcuration/contentcuration/viewsets/base.py b/contentcuration/contentcuration/viewsets/base.py index 7458df2245..f2e786ab07 100644 --- a/contentcuration/contentcuration/viewsets/base.py +++ b/contentcuration/contentcuration/viewsets/base.py @@ -635,7 +635,7 @@ def _map_create_change(self, change): + self.values_from_key(change["key"]) ) - def perform_create(self, serializer): + def perform_create(self, serializer, change=None): serializer.save() def create_from_changes(self, changes): @@ -645,7 +645,7 @@ def create_from_changes(self, changes): try: serializer = self.get_serializer(data=self._map_create_change(change)) if serializer.is_valid(): - self.perform_create(serializer) + self.perform_create(serializer, change=change) else: change.update({"errors": serializer.errors}) errors.append(change) diff --git a/contentcuration/contentcuration/viewsets/contentnode.py b/contentcuration/contentcuration/viewsets/contentnode.py index d6095813c6..f7f4aedb0d 100644 --- a/contentcuration/contentcuration/viewsets/contentnode.py +++ b/contentcuration/contentcuration/viewsets/contentnode.py @@ -982,7 +982,24 @@ def copy( {COPYING_FLAG: False, "node_id": new_node.node_id}, channel_id=channel_id ), - applied=True + applied=True, + created_by_id=self.request.user.id, ) return None + + def perform_create(self, serializer, change=None): + instance = serializer.save() + + # return change to the frontend for updating the `node_id` and `content_id` + if change is not None: + Change.create_change( + generate_update_event( + instance.pk, + CONTENTNODE, + {"node_id": instance.node_id, "content_id": instance.content_id}, + channel_id=change["channel_id"], + ), + created_by_id=change["created_by_id"], + applied=True + ) diff --git a/contentcuration/contentcuration/viewsets/sync/base.py b/contentcuration/contentcuration/viewsets/sync/base.py index 2402d3fae7..e80e716bfa 100644 --- a/contentcuration/contentcuration/viewsets/sync/base.py +++ b/contentcuration/contentcuration/viewsets/sync/base.py @@ -2,6 +2,7 @@ from search.viewsets.savedsearch import SavedSearchViewSet +from contentcuration.utils.user import delay_user_storage_calculation from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.channel import ChannelViewSet @@ -94,6 +95,7 @@ def get_change_type(obj): } +@delay_user_storage_calculation def apply_changes(changes_queryset): changes = changes_queryset.order_by("server_rev").select_related("created_by") for change in changes: diff --git a/contentcuration/contentcuration/viewsets/sync/endpoint.py b/contentcuration/contentcuration/viewsets/sync/endpoint.py index fb5d590bd8..17cb6dca68 100644 --- a/contentcuration/contentcuration/viewsets/sync/endpoint.py +++ b/contentcuration/contentcuration/viewsets/sync/endpoint.py @@ -122,7 +122,7 @@ def return_changes(self, request, channel_revs): def return_tasks(self, request, channel_revs): tasks = TaskResult.objects.filter( channel_id__in=channel_revs.keys(), - status__in=states.READY_STATES, + status__in=[states.STARTED, states.FAILURE], ).exclude(task_name__in=[apply_channel_changes_task.name, apply_user_changes_task.name]) return { "tasks": tasks.values(