Commit ea97dd45 by Ben McMorran

Tracks last edited date and user, and adds has_changes for blocks.

parent b60b7c7e
...@@ -239,6 +239,6 @@ def save_course_update_items(location, course_updates, course_update_items, user ...@@ -239,6 +239,6 @@ def save_course_update_items(location, course_updates, course_update_items, user
course_updates.data = _get_html(course_update_items) course_updates.data = _get_html(course_update_items)
# update db record # update db record
modulestore('direct').update_item(course_updates, user) modulestore('direct').update_item(course_updates, user.id)
return course_updates return course_updates
...@@ -135,7 +135,7 @@ class CourseUpdateTest(CourseTestCase): ...@@ -135,7 +135,7 @@ class CourseUpdateTest(CourseTestCase):
update_content = u"Hello world!" update_content = u"Hello world!"
update_data = u"<ol><li><h2>" + update_date + "</h2>" + update_content + "</li></ol>" update_data = u"<ol><li><h2>" + update_date + "</h2>" + update_content + "</li></ol>"
course_updates.data = update_data course_updates.data = update_data
modulestore('direct').update_item(course_updates, self.user) modulestore('direct').update_item(course_updates, self.user.id)
# test getting all updates list # test getting all updates list
course_update_url = self.create_update_url() course_update_url = self.create_update_url()
......
from xblock.fields import Scope from xblock.fields import Scope
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
from cms.lib.xblock.mixin import CmsBlockMixin
class CourseMetadata(object): class CourseMetadata(object):
...@@ -33,9 +32,6 @@ class CourseMetadata(object): ...@@ -33,9 +32,6 @@ class CourseMetadata(object):
result = {} result = {}
for field in descriptor.fields.values(): for field in descriptor.fields.values():
if field.name in CmsBlockMixin.fields:
continue
if field.scope != Scope.settings: if field.scope != Scope.settings:
continue continue
......
...@@ -33,7 +33,6 @@ from lms.envs.common import ( ...@@ -33,7 +33,6 @@ from lms.envs.common import (
from path import path from path import path
from lms.lib.xblock.mixin import LmsBlockMixin from lms.lib.xblock.mixin import LmsBlockMixin
from cms.lib.xblock.mixin import CmsBlockMixin
from dealer.git import git from dealer.git import git
############################ FEATURE CONFIGURATION ############################# ############################ FEATURE CONFIGURATION #############################
...@@ -238,7 +237,7 @@ from xmodule.x_module import XModuleMixin ...@@ -238,7 +237,7 @@ from xmodule.x_module import XModuleMixin
# This should be moved into an XBlock Runtime/Application object # This should be moved into an XBlock Runtime/Application object
# once the responsibility of XBlock creation is moved out of modulestore - cpennington # once the responsibility of XBlock creation is moved out of modulestore - cpennington
XBLOCK_MIXINS = (LmsBlockMixin, CmsBlockMixin, InheritanceMixin, XModuleMixin) XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin)
# Allow any XBlock in Studio # Allow any XBlock in Studio
# You should also enable the ALLOW_ALL_ADVANCED_COMPONENTS feature flag, so that # You should also enable the ALLOW_ALL_ADVANCED_COMPONENTS feature flag, so that
......
...@@ -19,11 +19,3 @@ class DateTuple(Field): ...@@ -19,11 +19,3 @@ class DateTuple(Field):
return None return None
return list(value.timetuple()) return list(value.timetuple())
class CmsBlockMixin(XBlockMixin):
"""
Mixin with fields common to all blocks in Studio
"""
published_date = DateTuple(help="Date when the module was published", scope=Scope.settings)
published_by = Integer(help="Id of the user who published this module", scope=Scope.settings)
...@@ -13,6 +13,7 @@ from xmodule.x_module import XModule, XModuleDescriptor ...@@ -13,6 +13,7 @@ from xmodule.x_module import XModule, XModuleDescriptor
from xmodule.errortracker import exc_info_to_str from xmodule.errortracker import exc_info_to_str
from xblock.fields import String, Scope, ScopeIds from xblock.fields import String, Scope, ScopeIds
from xblock.field_data import DictFieldData from xblock.field_data import DictFieldData
from xmodule.modulestore.xml_exporter import EdxJSONEncoder
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -121,7 +122,7 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor): ...@@ -121,7 +122,7 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor):
def from_json(cls, json_data, system, location, error_msg='Error not available'): def from_json(cls, json_data, system, location, error_msg='Error not available'):
return cls._construct( return cls._construct(
system, system,
json.dumps(json_data, skipkeys=False, indent=4), json.dumps(json_data, skipkeys=False, indent=4, cls=EdxJSONEncoder),
error_msg, error_msg,
location=location location=location
) )
......
...@@ -21,6 +21,8 @@ import re ...@@ -21,6 +21,8 @@ import re
from bson.son import SON from bson.son import SON
from fs.osfs import OSFS from fs.osfs import OSFS
from path import path from path import path
from datetime import datetime
from pytz import UTC
from importlib import import_module from importlib import import_module
from xmodule.errortracker import null_error_tracker, exc_info_to_str from xmodule.errortracker import null_error_tracker, exc_info_to_str
...@@ -206,6 +208,23 @@ class CachingDescriptorSystem(MakoDescriptorSystem): ...@@ -206,6 +208,23 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
# to python values # to python values
metadata_to_inherit = self.cached_metadata.get(non_draft_loc.to_deprecated_string(), {}) metadata_to_inherit = self.cached_metadata.get(non_draft_loc.to_deprecated_string(), {})
inherit_metadata(module, metadata_to_inherit) inherit_metadata(module, metadata_to_inherit)
edit_info = json_data.get('edit_info')
# migrate published_by and published_date if edit_info isn't present
if not edit_info:
module.edited_by = module.edited_on = module.published_date = None
# published_date was previously stored as a list of time components instead of a datetime
if metadata.get('published_date'):
module.published_date = datetime(*metadata.get('published_date')[0:6]).replace(tzinfo=UTC)
module.published_by = metadata.get('published_by')
# otherwise restore the stored editing information
else:
module.edited_by = edit_info.get('edited_by')
module.edited_on = edit_info.get('edited_on')
module.published_date = edit_info.get('published_date')
module.published_by = edit_info.get('published_by')
# decache any computed pending field settings # decache any computed pending field settings
module.save() module.save()
return module return module
...@@ -826,7 +845,7 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -826,7 +845,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
return xmodule return xmodule
def create_and_save_xmodule(self, location, definition_data=None, metadata=None, system=None, def create_and_save_xmodule(self, location, definition_data=None, metadata=None, system=None,
fields={}): fields={}, user_id=None):
""" """
Create the new xmodule and save it. Does not return the new module because if the caller Create the new xmodule and save it. Does not return the new module because if the caller
will insert it as a child, it's inherited metadata will completely change. The difference will insert it as a child, it's inherited metadata will completely change. The difference
...@@ -837,12 +856,13 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -837,12 +856,13 @@ class MongoModuleStore(ModuleStoreWriteBase):
:param definition_data: can be empty. The initial definition_data for the kvs :param definition_data: can be empty. The initial definition_data for the kvs
:param metadata: can be empty, the initial metadata for the kvs :param metadata: can be empty, the initial metadata for the kvs
:param system: if you already have an xblock from the course, the xblock.runtime value :param system: if you already have an xblock from the course, the xblock.runtime value
:param user_id: the user that created the xblock
""" """
# differs from split mongo in that I believe most of this logic should be above the persistence # differs from split mongo in that I believe most of this logic should be above the persistence
# layer but added it here to enable quick conversion. I'll need to reconcile these. # layer but added it here to enable quick conversion. I'll need to reconcile these.
new_object = self.create_xmodule(location, definition_data, metadata, system, fields) new_object = self.create_xmodule(location, definition_data, metadata, system, fields)
location = new_object.scope_ids.usage_id location = new_object.scope_ids.usage_id
self.update_item(new_object, allow_not_found=True) self.update_item(new_object, allow_not_found=True, user_id=user_id)
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
# if we add one then we need to also add it to the policy information (i.e. metadata) # if we add one then we need to also add it to the policy information (i.e. metadata)
...@@ -856,7 +876,7 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -856,7 +876,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
url_slug=new_object.scope_ids.usage_id.name, url_slug=new_object.scope_ids.usage_id.name,
) )
) )
self.update_item(course) self.update_item(course, user_id=user_id)
return new_object return new_object
...@@ -888,7 +908,7 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -888,7 +908,7 @@ class MongoModuleStore(ModuleStoreWriteBase):
if result['n'] == 0: if result['n'] == 0:
raise ItemNotFoundError(location) raise ItemNotFoundError(location)
def update_item(self, xblock, user_id=None, allow_not_found=False, force=False): def update_item(self, xblock, user_id=None, allow_not_found=False, force=False, isPublish=False):
""" """
Update the persisted version of xblock to reflect its current values. Update the persisted version of xblock to reflect its current values.
...@@ -902,7 +922,16 @@ class MongoModuleStore(ModuleStoreWriteBase): ...@@ -902,7 +922,16 @@ class MongoModuleStore(ModuleStoreWriteBase):
payload = { payload = {
'definition.data': definition_data, 'definition.data': definition_data,
'metadata': self._convert_reference_fields_to_strings(xblock, own_metadata(xblock)), 'metadata': self._convert_reference_fields_to_strings(xblock, own_metadata(xblock)),
'edit_info': {
'edited_on': datetime.now(UTC),
'edited_by': user_id,
}
} }
if isPublish:
payload['edit_info']['published_date'] = datetime.now(UTC)
payload['edit_info']['published_by'] = user_id
if xblock.has_children: if xblock.has_children:
children = self._convert_reference_fields_to_strings(xblock, {'children': xblock.children}) children = self._convert_reference_fields_to_strings(xblock, {'children': xblock.children})
payload.update({'definition.children': children['children']}) payload.update({'definition.children': children['children']})
......
...@@ -157,7 +157,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -157,7 +157,7 @@ class DraftModuleStore(MongoModuleStore):
return wrap_draft(self._load_items(source_location.course_key, [original])[0]) return wrap_draft(self._load_items(source_location.course_key, [original])[0])
def update_item(self, xblock, user_id=None, allow_not_found=False, force=False): def update_item(self, xblock, user_id=None, allow_not_found=False, force=False, isPublish=False):
""" """
See superclass doc. See superclass doc.
In addition to the superclass's behavior, this method converts the unit to draft if it's not In addition to the superclass's behavior, this method converts the unit to draft if it's not
...@@ -175,7 +175,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -175,7 +175,7 @@ class DraftModuleStore(MongoModuleStore):
raise raise
xblock.location = draft_loc xblock.location = draft_loc
super(DraftModuleStore, self).update_item(xblock, user_id, allow_not_found) super(DraftModuleStore, self).update_item(xblock, user_id, allow_not_found, isPublish)
# don't allow locations to truly represent themselves as draft outside of this file # don't allow locations to truly represent themselves as draft outside of this file
xblock.location = as_published(xblock.location) xblock.location = as_published(xblock.location)
...@@ -194,6 +194,30 @@ class DraftModuleStore(MongoModuleStore): ...@@ -194,6 +194,30 @@ class DraftModuleStore(MongoModuleStore):
return return
def has_changes(self, location):
"""
Check if the xblock has been changed since it was last published.
:param location: location to check
:return: True if the draft and published versions differ
"""
# Direct only categories can never have changes because they can't have drafts
if location.category in DIRECT_ONLY_CATEGORIES:
return False
draft = self.get_item(location)
# If the draft was never published, then it clearly has unpublished changes
if not draft.published_date:
return True
# edited_on may be None if the draft was last edited before edit time tracking
# If the draft does not have an edit time, we play it safe and assume there are differences
if draft.edited_on:
return draft.edited_on > draft.published_date
else:
return True
def publish(self, location, published_by_id): def publish(self, location, published_by_id):
""" """
Save a current draft to the underlying modulestore Save a current draft to the underlying modulestore
...@@ -209,8 +233,6 @@ class DraftModuleStore(MongoModuleStore): ...@@ -209,8 +233,6 @@ class DraftModuleStore(MongoModuleStore):
draft = self.get_item(location) draft = self.get_item(location)
draft.published_date = datetime.now(UTC)
draft.published_by = published_by_id
if draft.has_children: if draft.has_children:
if original_published is not None: if original_published is not None:
# see if children were deleted. 2 reasons for children lists to differ: # see if children were deleted. 2 reasons for children lists to differ:
...@@ -221,7 +243,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -221,7 +243,7 @@ class DraftModuleStore(MongoModuleStore):
rents = self.get_parent_locations(child) rents = self.get_parent_locations(child)
if (len(rents) == 1 and rents[0] == location): # the 1 is this original_published if (len(rents) == 1 and rents[0] == location): # the 1 is this original_published
self.delete_item(child, True) self.delete_item(child, True)
super(DraftModuleStore, self).update_item(draft, '**replace_user**') super(DraftModuleStore, self).update_item(draft, published_by_id, isPublish=True)
self.delete_item(location) self.delete_item(location)
def unpublish(self, location): def unpublish(self, location):
......
...@@ -371,6 +371,20 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): ...@@ -371,6 +371,20 @@ class SplitMongoModuleStore(ModuleStoreWriteBase):
return self._get_block_from_structure(course_structure, usage_key.block_id) is not None return self._get_block_from_structure(course_structure, usage_key.block_id) is not None
def has_changes(self, usage_key):
"""
Checks if the given block has unpublished changes
:param usage_key: the block to check
:return: True if the draft and published versions differ
"""
draft = self.get_item(usage_key.for_branch("draft"))
try:
published = self.get_item(usage_key.for_branch("published"))
except ItemNotFoundError:
return True
return draft.update_version != published.update_version
def get_item(self, usage_key, depth=0): def get_item(self, usage_key, depth=0):
""" """
depth (int): An argument that some module stores may use to prefetch depth (int): An argument that some module stores may use to prefetch
......
...@@ -8,6 +8,8 @@ import logging ...@@ -8,6 +8,8 @@ import logging
import shutil import shutil
from tempfile import mkdtemp from tempfile import mkdtemp
from uuid import uuid4 from uuid import uuid4
from datetime import datetime
from pytz import UTC
import unittest import unittest
from xblock.core import XBlock from xblock.core import XBlock
...@@ -21,6 +23,7 @@ from opaque_keys.edx.locations import Location ...@@ -21,6 +23,7 @@ from opaque_keys.edx.locations import Location
from xmodule.modulestore import MONGO_MODULESTORE_TYPE from xmodule.modulestore import MONGO_MODULESTORE_TYPE
from xmodule.modulestore.mongo import MongoModuleStore, MongoKeyValueStore from xmodule.modulestore.mongo import MongoModuleStore, MongoKeyValueStore
from xmodule.modulestore.draft import DraftModuleStore from xmodule.modulestore.draft import DraftModuleStore
from xmodule.modulestore.mongo.draft import as_draft
from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation
from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
...@@ -471,6 +474,120 @@ class TestMongoModuleStore(unittest.TestCase): ...@@ -471,6 +474,120 @@ class TestMongoModuleStore(unittest.TestCase):
finally: finally:
shutil.rmtree(root_dir) shutil.rmtree(root_dir)
def test_has_changes_direct_only(self):
"""
Tests that has_changes() returns false when an xblock in a direct only category is checked
"""
course_location = Location('edx', 'direct', '2012_Fall', 'course', 'test_course')
chapter_location = Location('edx', 'direct', '2012_Fall', 'chapter', 'test_chapter')
dummy_user = 123
# Create dummy direct only xblocks
self.draft_store.create_and_save_xmodule(course_location, user_id=dummy_user)
self.draft_store.create_and_save_xmodule(chapter_location, user_id=dummy_user)
# Check that neither xblock has changes
self.assertFalse(self.draft_store.has_changes(course_location))
self.assertFalse(self.draft_store.has_changes(chapter_location))
def test_has_changes(self):
"""
Tests that has_changes() only returns true when changes are present
"""
location = Location('edX', 'changes', '2012_Fall', 'vertical', 'test_vertical')
dummy_user = 123
# Create a dummy component to test against
self.draft_store.create_and_save_xmodule(location, user_id=dummy_user)
# Not yet published, so changes are present
self.assertTrue(self.draft_store.has_changes(location))
# Publish and verify that there are no unpublished changes
self.draft_store.publish(location, dummy_user)
self.assertFalse(self.draft_store.has_changes(location))
# Change the component, then check that there now are changes
component = self.draft_store.get_item(location)
component.display_name = 'Changed Display Name'
self.draft_store.update_item(component, dummy_user)
self.assertTrue(self.draft_store.has_changes(location))
# Publish and verify again
self.draft_store.publish(location, dummy_user)
self.assertFalse(self.draft_store.has_changes(location))
def test_update_edit_info(self):
"""
Tests that edited_on and edited_by are set correctly during an update
"""
location = Location('edX', 'editInfoTest', '2012_Fall', 'html', 'test_html')
dummy_user = 123
# Create a dummy component to test against
self.draft_store.create_and_save_xmodule(location, user_id=dummy_user)
# Store the current edit time and verify that dummy_user created the component
component = self.draft_store.get_item(location)
self.assertEqual(component.edited_by, dummy_user)
old_edited_on = component.edited_on
# Change the component
component.display_name = component.display_name + ' Changed'
self.draft_store.update_item(component, dummy_user)
updated_component = self.draft_store.get_item(location)
# Verify the ordering of edit times and that dummy_user made the edit
self.assertLess(old_edited_on, updated_component.edited_on)
self.assertEqual(updated_component.edited_by, dummy_user)
def test_update_published_info(self):
"""
Tests that published_date and published_by are set correctly
"""
location = Location('edX', 'publishInfo', '2012_Fall', 'html', 'test_html')
create_user = 123
publish_user = 456
# Create a dummy component to test against
self.draft_store.create_and_save_xmodule(location, user_id=create_user)
# Store the current time, then publish
old_time = datetime.now(UTC)
self.draft_store.publish(location, publish_user)
updated_component = self.draft_store.get_item(location)
# Verify the time order and that publish_user caused publication
self.assertLessEqual(old_time, updated_component.published_date)
self.assertEqual(updated_component.published_by, publish_user)
def test_migrate_published_info(self):
"""
Tests that blocks that were storing published_date and published_by through CMSBlockMixin are loaded correctly
"""
# Insert the test block directly into the module store
location = Location('edX', 'migration', '2012_Fall', 'html', 'test_html')
published_date = datetime(1970, 1, 1, tzinfo=UTC)
published_by = 123
self.store._update_single_item(
as_draft(location),
{
'definition.data': {},
'metadata': {
# published_date was previously stored as a list of time components, not a datetime
'published_date': list(published_date.timetuple()),
'published_by': published_by,
},
},
)
# Retrieve the block and verify its fields
component = self.draft_store.get_item(location)
self.assertEqual(component.published_date, published_date)
self.assertEqual(component.published_by, published_by)
class TestMongoKeyValueStore(object): class TestMongoKeyValueStore(object):
""" """
......
...@@ -805,6 +805,32 @@ class SplitModuleItemTests(SplitModuleTest): ...@@ -805,6 +805,32 @@ class SplitModuleItemTests(SplitModuleTest):
with self.assertRaises(ItemNotFoundError): with self.assertRaises(ItemNotFoundError):
modulestore().get_item(course.location.for_branch("published")) modulestore().get_item(course.location.for_branch("published"))
def test_has_changes(self):
"""
Tests that has_changes() only returns true when changes are present
"""
draft_course = CourseLocator(org='testx', offering='GreekHero', branch='draft')
published_course = CourseLocator(org='testx', offering='GreekHero', branch='published')
head = draft_course.make_usage_key('course', 'head12345')
dummy_user = 'testUser'
# Not yet published, so changes are present
self.assertTrue(modulestore().has_changes(head))
# Publish and verify that there are no unpublished changes
modulestore().xblock_publish(dummy_user, draft_course, published_course, [head], None)
self.assertFalse(modulestore().has_changes(head))
# Change the course, then check that there now are changes
course = modulestore().get_item(head)
course.show_calculator = not course.show_calculator
modulestore().update_item(course, dummy_user)
self.assertTrue(modulestore().has_changes(head))
# Publish and verify again
modulestore().xblock_publish(dummy_user, draft_course, published_course, [head], None)
self.assertFalse(modulestore().has_changes(head))
def test_get_non_root(self): def test_get_non_root(self):
# not a course obj # not a course obj
locator = BlockUsageLocator( locator = BlockUsageLocator(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment