Commit 5ffc6ac2 by John Eskew

Merge pull request #4854 from edx/jeskew/assetstore_modulestore_work

Phase 1 of adding asset metadata saving to old Mongo
parents 3d1c54fe b857a0ed
"""
Classes representing asset & asset thumbnail metadata.
"""
from datetime import datetime
from contracts import contract, new_contract
from opaque_keys.edx.keys import CourseKey, AssetKey
new_contract('AssetKey', AssetKey)
new_contract('datetime', datetime)
class IncorrectAssetIdType(Exception):
"""
Raised when the asset ID passed-in to create an AssetMetadata or
AssetThumbnailMetadata is of the wrong type.
"""
pass
class AssetMetadata(object):
"""
Stores the metadata associated with a particular course asset. The asset metadata gets stored
in the modulestore.
"""
TOP_LEVEL_ATTRS = ['basename', 'internal_name', 'locked', 'contenttype', 'md5']
EDIT_INFO_ATTRS = ['curr_version', 'prev_version', 'edited_by', 'edited_on']
ALLOWED_ATTRS = TOP_LEVEL_ATTRS + EDIT_INFO_ATTRS
# All AssetMetadata objects should have AssetLocators with this type.
ASSET_TYPE = 'asset'
@contract(asset_id='AssetKey', basename='str | unicode | None', internal_name='str | None', locked='bool | None',
contenttype='str | unicode | None', md5='str | None', curr_version='str | None', prev_version='str | None')
def __init__(self, asset_id,
basename=None, internal_name=None,
locked=None, contenttype=None, md5=None,
curr_version=None, prev_version=None):
"""
Construct a AssetMetadata object.
Arguments:
asset_id (AssetKey): Key identifying this particular asset.
basename (str): Original path to file at asset upload time.
internal_name (str): Name under which the file is stored internally.
locked (bool): If True, only course participants can access the asset.
contenttype (str): MIME type of the asset.
curr_version (str): Current version of the asset.
prev_version (str): Previous version of the asset.
"""
if asset_id.asset_type != self.ASSET_TYPE:
raise IncorrectAssetIdType()
self.asset_id = asset_id
self.basename = basename # Path w/o filename.
self.internal_name = internal_name
self.locked = locked
self.contenttype = contenttype
self.md5 = md5
self.curr_version = curr_version
self.prev_version = prev_version
self.edited_by = None
self.edited_on = None
def __repr__(self):
return """AssetMetadata{!r}""".format((
self.asset_id,
self.basename, self.internal_name,
self.locked, self.contenttype, self.md5,
self.curr_version, self.prev_version,
self.edited_by, self.edited_on
))
def update(self, attr_dict):
"""
Set the attributes on the metadata. Ignore all those outside the known fields.
Arguments:
attr_dict: Prop, val dictionary of all attributes to set.
"""
for attr, val in attr_dict.iteritems():
if attr in self.ALLOWED_ATTRS:
setattr(self, attr, val)
def to_mongo(self):
"""
Converts metadata properties into a MongoDB-storable dict.
"""
return {
'filename': self.asset_id.path,
'basename': self.basename,
'internal_name': self.internal_name,
'locked': self.locked,
'contenttype': self.contenttype,
'md5': self.md5,
'edit_info': {
'curr_version': self.curr_version,
'prev_version': self.prev_version,
'edited_by': self.edited_by,
'edited_on': self.edited_on
}
}
@contract(asset_doc='dict | None')
def from_mongo(self, asset_doc):
"""
Fill in all metadata fields from a MongoDB document.
The asset_id prop is initialized upon construction only.
"""
if asset_doc is None:
return
self.basename = asset_doc['basename']
self.internal_name = asset_doc['internal_name']
self.locked = asset_doc['locked']
self.contenttype = asset_doc['contenttype']
self.md5 = asset_doc['md5']
edit_info = asset_doc['edit_info']
self.curr_version = edit_info['curr_version']
self.prev_version = edit_info['prev_version']
self.edited_by = edit_info['edited_by']
self.edited_on = edit_info['edited_on']
class AssetThumbnailMetadata(object):
"""
Stores the metadata associated with the thumbnail of a course asset.
"""
# All AssetThumbnailMetadata objects should have AssetLocators with this type.
ASSET_TYPE = 'thumbnail'
@contract(asset_id='AssetKey', internal_name='str | unicode | None')
def __init__(self, asset_id, internal_name=None):
"""
Construct a AssetThumbnailMetadata object.
Arguments:
asset_id (AssetKey): Key identifying this particular asset.
internal_name (str): Name under which the file is stored internally.
"""
if asset_id.asset_type != self.ASSET_TYPE:
raise IncorrectAssetIdType()
self.asset_id = asset_id
self.internal_name = internal_name
def __repr__(self):
return """AssetMetadata{!r}""".format((self.asset_id, self.internal_name))
def to_mongo(self):
"""
Converts metadata properties into a MongoDB-storable dict.
"""
return {
'filename': self.asset_id.path,
'internal_name': self.internal_name
}
@contract(thumbnail_doc='dict | None')
def from_mongo(self, thumbnail_doc):
"""
Fill in all metadata fields from a MongoDB document.
The asset_id prop is initialized upon construction only.
"""
if thumbnail_doc is None:
return
self.internal_name = thumbnail_doc['internal_name']
...@@ -12,23 +12,30 @@ from uuid import uuid4 ...@@ -12,23 +12,30 @@ from uuid import uuid4
from collections import namedtuple, defaultdict from collections import namedtuple, defaultdict
import collections import collections
from contextlib import contextmanager from contextlib import contextmanager
import functools
import threading
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from contracts import contract, new_contract
from xblock.plugin import default_select from xblock.plugin import default_select
from .exceptions import InvalidLocationError, InsufficientSpecificationError from .exceptions import InvalidLocationError, InsufficientSpecificationError
from xmodule.errortracker import make_error_tracker from xmodule.errortracker import make_error_tracker
from opaque_keys.edx.keys import CourseKey, UsageKey from xmodule.assetstore import AssetMetadata, AssetThumbnailMetadata
from opaque_keys.edx.keys import CourseKey, UsageKey, AssetKey
from opaque_keys.edx.locations import Location # For import backwards compatibility from opaque_keys.edx.locations import Location # For import backwards compatibility
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xblock.runtime import Mixologist from xblock.runtime import Mixologist
from xblock.core import XBlock from xblock.core import XBlock
import functools
import threading
log = logging.getLogger('edx.modulestore') log = logging.getLogger('edx.modulestore')
new_contract('CourseKey', CourseKey)
new_contract('AssetKey', AssetKey)
new_contract('AssetMetadata', AssetMetadata)
new_contract('AssetThumbnailMetadata', AssetThumbnailMetadata)
class ModuleStoreEnum(object): class ModuleStoreEnum(object):
""" """
...@@ -740,6 +747,9 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead): ...@@ -740,6 +747,9 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead):
""" """
@functools.wraps(func) @functools.wraps(func)
def wrapper(self, *args, **kwargs): def wrapper(self, *args, **kwargs):
"""
Wraps a method to memoize results.
"""
if self.request_cache: if self.request_cache:
cache_key = '&'.join([hashvalue(arg) for arg in args]) cache_key = '&'.join([hashvalue(arg) for arg in args])
if cache_key in self.request_cache.data.setdefault(func.__name__, {}): if cache_key in self.request_cache.data.setdefault(func.__name__, {}):
...@@ -863,6 +873,269 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite): ...@@ -863,6 +873,269 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
parent.children.append(item.location) parent.children.append(item.location)
self.update_item(parent, user_id) self.update_item(parent, user_id)
def _find_course_assets(self, course_key):
"""
Base method to override.
"""
raise NotImplementedError()
def _find_course_asset(self, course_key, filename, get_thumbnail=False):
"""
Internal; finds or creates course asset info -and- finds existing asset (or thumbnail) metadata.
Arguments:
course_key (CourseKey): course identifier
filename (str): filename of the asset or thumbnail
get_thumbnail (bool): True gets thumbnail data, False gets asset data
Returns:
Asset info for the course, index of asset/thumbnail in list (None if asset/thumbnail does not exist)
"""
course_assets = self._find_course_assets(course_key)
if get_thumbnail:
all_assets = course_assets['thumbnails']
else:
all_assets = course_assets['assets']
# See if this asset already exists by checking the external_filename.
# Studio doesn't currently support using multiple course assets with the same filename.
# So use the filename as the unique identifier.
for idx, asset in enumerate(all_assets):
if asset['filename'] == filename:
return course_assets, idx
return course_assets, None
def _save_asset_info(self, course_key, asset_metadata, user_id, thumbnail=False):
"""
Base method to over-ride in modulestore.
"""
raise NotImplementedError()
@contract(course_key='CourseKey', asset_metadata='AssetMetadata', user_id='str | unicode')
def save_asset_metadata(self, course_key, asset_metadata, user_id):
"""
Saves the asset metadata for a particular course's asset.
Arguments:
course_key (CourseKey): course identifier
asset_metadata (AssetMetadata): data about the course asset data
Returns:
True if metadata save was successful, else False
"""
return self._save_asset_info(course_key, asset_metadata, user_id, thumbnail=False)
@contract(course_key='CourseKey', asset_thumbnail_metadata='AssetThumbnailMetadata')
def save_asset_thumbnail_metadata(self, course_key, asset_thumbnail_metadata):
"""
Saves the asset thumbnail metadata for a particular course asset's thumbnail.
Arguments:
course_key (CourseKey): course identifier
asset_thumbnail_metadata (AssetThumbnailMetadata): data about the course asset thumbnail
Returns:
True if thumbnail metadata save was successful, else False
"""
return self._save_asset_info(course_key, asset_thumbnail_metadata, '', thumbnail=True)
@contract(asset_key='AssetKey')
def _find_asset_info(self, asset_key, thumbnail=False):
"""
Find the info for a particular course asset/thumbnail.
Arguments:
asset_key (AssetKey): key containing original asset filename
thumbnail (bool): True if finding thumbnail, False if finding asset metadata
Returns:
asset/thumbnail metadata (AssetMetadata/AssetThumbnailMetadata) -or- None if not found
"""
course_assets, asset_idx = self._find_course_asset(asset_key.course_key, asset_key.path, thumbnail)
if asset_idx is None:
return None
if thumbnail:
info = 'thumbnails'
mdata = AssetThumbnailMetadata(asset_key, asset_key.path)
else:
info = 'assets'
mdata = AssetMetadata(asset_key, asset_key.path)
all_assets = course_assets[info]
mdata.from_mongo(all_assets[asset_idx])
return mdata
@contract(asset_key='AssetKey')
def find_asset_metadata(self, asset_key):
"""
Find the metadata for a particular course asset.
Arguments:
asset_key (AssetKey): key containing original asset filename
Returns:
asset metadata (AssetMetadata) -or- None if not found
"""
return self._find_asset_info(asset_key, thumbnail=False)
@contract(asset_key='AssetKey')
def find_asset_thumbnail_metadata(self, asset_key):
"""
Find the metadata for a particular course asset.
Arguments:
asset_key (AssetKey): key containing original asset filename
Returns:
asset metadata (AssetMetadata) -or- None if not found
"""
return self._find_asset_info(asset_key, thumbnail=True)
@contract(course_key='CourseKey', start='int | None', maxresults='int | None', sort='list | None', get_thumbnails='bool')
def _get_all_asset_metadata(self, course_key, start=0, maxresults=-1, sort=None, get_thumbnails=False):
"""
Returns a list of static asset (or thumbnail) metadata for a course.
Args:
course_key (CourseKey): course identifier
start (int): optional - start at this asset number
maxresults (int): optional - return at most this many, -1 means no limit
sort (array): optional - None means no sort
(sort_by (str), sort_order (str))
sort_by - one of 'uploadDate' or 'displayname'
sort_order - one of 'ascending' or 'descending'
get_thumbnails (bool): True if getting thumbnail metadata, else getting asset metadata
Returns:
List of AssetMetadata or AssetThumbnailMetadata objects.
"""
course_assets = self._find_course_assets(course_key)
if course_assets is None:
# If no course assets are found, return None instead of empty list
# to distinguish zero assets from "not able to retrieve assets".
return None
if get_thumbnails:
all_assets = course_assets['thumbnails']
else:
all_assets = course_assets['assets']
# DO_NEXT: Add start/maxresults/sort functionality as part of https://openedx.atlassian.net/browse/PLAT-74
if start and maxresults and sort:
pass
ret_assets = []
for asset in all_assets:
if get_thumbnails:
thumb = AssetThumbnailMetadata(course_key.make_asset_key('thumbnail', asset['filename']),
internal_name=asset['filename'])
ret_assets.append(thumb)
else:
one_asset = AssetMetadata(course_key.make_asset_key('asset', asset['filename']))
one_asset.from_mongo(asset)
ret_assets.append(one_asset)
return ret_assets
@contract(course_key='CourseKey', start='int | None', maxresults='int | None', sort='list | None')
def get_all_asset_metadata(self, course_key, start=0, maxresults=-1, sort=None):
"""
Returns a list of static assets for a course.
By default all assets are returned, but start and maxresults can be provided to limit the query.
Args:
course_key (CourseKey): course identifier
start (int): optional - start at this asset number
maxresults (int): optional - return at most this many, -1 means no limit
sort (array): optional - None means no sort
(sort_by (str), sort_order (str))
sort_by - one of 'uploadDate' or 'displayname'
sort_order - one of 'ascending' or 'descending'
Returns:
List of AssetMetadata objects.
"""
return self._get_all_asset_metadata(course_key, start, maxresults, sort, get_thumbnails=False)
@contract(course_key='CourseKey')
def get_all_asset_thumbnail_metadata(self, course_key):
"""
Returns a list of thumbnails for all course assets.
Args:
course_key (CourseKey): course identifier
Returns:
List of AssetThumbnailMetadata objects.
"""
return self._get_all_asset_metadata(course_key, get_thumbnails=True)
def set_asset_metadata_attrs(self, asset_key, attrs, user_id):
"""
Base method to over-ride in modulestore.
"""
raise NotImplementedError()
def _delete_asset_data(self, asset_key, thumbnail=False):
"""
Base method to over-ride in modulestore.
"""
raise NotImplementedError()
@contract(asset_key='AssetKey', attr=str)
def set_asset_metadata_attr(self, asset_key, attr, value, user_id):
"""
Add/set the given attr on the asset at the given location. Value can be any type which pymongo accepts.
Arguments:
asset_key (AssetKey): asset identifier
attr (str): which attribute to set
value: the value to set it to (any type pymongo accepts such as datetime, number, string)
Raises:
ItemNotFoundError if no such item exists
AttributeError is attr is one of the build in attrs.
"""
return self.set_asset_metadata_attrs(asset_key, {attr: value}, user_id)
@contract(asset_key='AssetKey')
def delete_asset_metadata(self, asset_key):
"""
Deletes a single asset's metadata.
Arguments:
asset_key (AssetKey): locator containing original asset filename
Returns:
Number of asset metadata entries deleted (0 or 1)
"""
return self._delete_asset_data(asset_key, thumbnail=False)
@contract(asset_key='AssetKey')
def delete_asset_thumbnail_metadata(self, asset_key):
"""
Deletes a single asset's metadata.
Arguments:
asset_key (AssetKey): locator containing original asset filename
Returns:
Number of asset metadata entries deleted (0 or 1)
"""
return self._delete_asset_data(asset_key, thumbnail=True)
@contract(source_course_key='CourseKey', dest_course_key='CourseKey')
def copy_all_asset_metadata(self, source_course_key, dest_course_key):
"""
Copy all the course assets from source_course_key to dest_course_key.
Arguments:
source_course_key (CourseKey): identifier of course to copy from
dest_course_key (CourseKey): identifier of course to copy to
"""
pass
def only_xmodules(identifier, entry_points): def only_xmodules(identifier, entry_points):
"""Only use entry_points that are supplied by the xmodule package""" """Only use entry_points that are supplied by the xmodule package"""
......
...@@ -9,10 +9,12 @@ import logging ...@@ -9,10 +9,12 @@ import logging
from contextlib import contextmanager from contextlib import contextmanager
import itertools import itertools
import functools import functools
from contracts import contract, new_contract
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey, AssetKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.assetstore import AssetMetadata, AssetThumbnailMetadata
from . import ModuleStoreWriteBase from . import ModuleStoreWriteBase
from . import ModuleStoreEnum from . import ModuleStoreEnum
...@@ -20,6 +22,10 @@ from .exceptions import ItemNotFoundError, DuplicateCourseError ...@@ -20,6 +22,10 @@ from .exceptions import ItemNotFoundError, DuplicateCourseError
from .draft_and_published import ModuleStoreDraftAndPublished from .draft_and_published import ModuleStoreDraftAndPublished
from .split_migrator import SplitMigrator from .split_migrator import SplitMigrator
new_contract('CourseKey', CourseKey)
new_contract('AssetKey', AssetKey)
new_contract('AssetMetadata', AssetMetadata)
new_contract('AssetThumbnailMetadata', AssetThumbnailMetadata)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -309,6 +315,209 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): ...@@ -309,6 +315,209 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
store = self._get_modulestore_for_courseid(course_key) store = self._get_modulestore_for_courseid(course_key)
return store.delete_course(course_key, user_id) return store.delete_course(course_key, user_id)
def _save_asset_info(self, course_key, asset_metadata, user_id, thumbnail=False):
"""
Base method to over-ride in modulestore.
"""
raise NotImplementedError()
def _delete_asset_data(self, asset_key, thumbnail=False):
"""
Base method to over-ride in modulestore.
"""
raise NotImplementedError()
def _find_course_assets(self, course_key):
"""
Base method to override.
"""
raise NotImplementedError()
@contract(course_key='CourseKey', asset_metadata='AssetMetadata')
def save_asset_metadata(self, course_key, asset_metadata, user_id):
"""
Saves the asset metadata for a particular course's asset.
Args:
course_key (CourseKey): course identifier
asset_metadata (AssetMetadata): data about the course asset data
Returns:
bool: True if metadata save was successful, else False
"""
store = self._get_modulestore_for_courseid(course_key)
return store.save_asset_metadata(course_key, asset_metadata, user_id)
@contract(course_key='CourseKey', asset_thumbnail_metadata='AssetThumbnailMetadata')
def save_asset_thumbnail_metadata(self, course_key, asset_thumbnail_metadata):
"""
Saves the asset thumbnail metadata for a particular course asset's thumbnail.
Arguments:
course_key (CourseKey): course identifier
asset_thumbnail_metadata (AssetThumbnailMetadata): data about the course asset thumbnail
Returns:
True if thumbnail metadata save was successful, else False
"""
store = self._get_modulestore_for_courseid(course_key)
return store.save_asset_metadata(course_key, asset_thumbnail_metadata)
@contract(asset_key='AssetKey')
def find_asset_metadata(self, asset_key):
"""
Find the metadata for a particular course asset.
Args:
asset_key (AssetKey): locator containing original asset filename
Returns:
asset metadata (AssetMetadata) -or- None if not found
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.find_asset_metadata(asset_key)
@contract(asset_key='AssetKey')
def find_asset_thumbnail_metadata(self, asset_key):
"""
Find the metadata for a particular course asset.
Arguments:
asset_key (AssetKey): key containing original asset filename
Returns:
asset metadata (AssetMetadata) -or- None if not found
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.find_asset_thumbnail_metadata(asset_key)
@contract(course_key='CourseKey', start=int, maxresults=int, sort='list | None')
def get_all_asset_metadata(self, course_key, start=0, maxresults=-1, sort=None):
"""
Returns a list of static assets for a course.
By default all assets are returned, but start and maxresults can be provided to limit the query.
Args:
course_key (CourseKey): course identifier
start (int): optional - start at this asset number
maxresults (int): optional - return at most this many, -1 means no limit
sort (array): optional - None means no sort
(sort_by (str), sort_order (str))
sort_by - one of 'uploadDate' or 'displayname'
sort_order - one of 'ascending' or 'descending'
Returns:
List of asset data dictionaries, which have the following keys:
asset_key (AssetKey): asset identifier
displayname: The human-readable name of the asset
uploadDate (datetime.datetime): The date and time that the file was uploaded
contentType: The mimetype string of the asset
md5: An md5 hash of the asset content
"""
store = self._get_modulestore_for_courseid(course_key)
return store.get_all_asset_metadata(course_key, start, maxresults, sort)
@contract(course_key='CourseKey')
def get_all_asset_thumbnail_metadata(self, course_key):
"""
Returns a list of thumbnails for all course assets.
Args:
course_key (CourseKey): course identifier
Returns:
List of AssetThumbnailMetadata objects.
"""
store = self._get_modulestore_for_courseid(course_key)
return store.get_all_asset_thumbnail_metadata(course_key)
@contract(asset_key='AssetKey')
def delete_asset_metadata(self, asset_key):
"""
Deletes a single asset's metadata.
Arguments:
asset_id (AssetKey): locator containing original asset filename
Returns:
Number of asset metadata entries deleted (0 or 1)
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.delete_asset_metadata(asset_key)
@contract(asset_key='AssetKey')
def delete_asset_thumbnail_metadata(self, asset_key):
"""
Deletes a single asset's metadata.
Arguments:
asset_key (AssetKey): locator containing original asset filename
Returns:
Number of asset metadata entries deleted (0 or 1)
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.delete_asset_metadata(asset_key)
@contract(course_key='CourseKey')
def delete_all_asset_metadata(self, course_key):
"""
Delete all of the assets which use this course_key as an identifier.
Arguments:
course_key (CourseKey): course_identifier
"""
store = self._get_modulestore_for_courseid(course_key)
return store.delete_all_asset_metadata(course_key)
@contract(source_course_key='CourseKey', dest_course_key='CourseKey')
def copy_all_asset_metadata(self, source_course_key, dest_course_key):
"""
Copy all the course assets from source_course_key to dest_course_key.
Arguments:
source_course_key (CourseKey): identifier of course to copy from
dest_course_key (CourseKey): identifier of course to copy to
"""
# When implementing this in https://openedx.atlassian.net/browse/PLAT-78 , consider this:
# Check the modulestores of both the source and dest course_keys. If in different modulestores,
# export all asset data from one modulestore and import it into the dest one.
store = self._get_modulestore_for_courseid(source_course_key)
return store.copy_all_asset_metadata(source_course_key, dest_course_key)
@contract(asset_key='AssetKey', attr=str)
def set_asset_metadata_attr(self, asset_key, attr, value, user_id):
"""
Add/set the given attr on the asset at the given location. Value can be any type which pymongo accepts.
Arguments:
asset_key (AssetKey): asset identifier
attr (str): which attribute to set
value: the value to set it to (any type pymongo accepts such as datetime, number, string)
Raises:
NotFoundError if no such item exists
AttributeError is attr is one of the build in attrs.
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.set_asset_metadata_attrs(asset_key, attr, value, user_id)
@contract(asset_key='AssetKey', attr_dict=dict)
def set_asset_metadata_attrs(self, asset_key, attr_dict, user_id):
"""
Add/set the given dict of attrs on the asset at the given location. Value can be any type which pymongo accepts.
Arguments:
asset_key (AssetKey): asset identifier
attr_dict (dict): attribute/value pairs to set
Raises:
NotFoundError if no such item exists
AttributeError is attr is one of the build in attrs.
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.set_asset_metadata_attrs(asset_key, attr_dict, user_id)
@strip_key @strip_key
def get_parent_location(self, location, **kwargs): def get_parent_location(self, location, **kwargs):
""" """
......
...@@ -24,6 +24,7 @@ from fs.osfs import OSFS ...@@ -24,6 +24,7 @@ from fs.osfs import OSFS
from path import path from path import path
from datetime import datetime from datetime import datetime
from pytz import UTC from pytz import UTC
from contracts import contract, new_contract
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
...@@ -41,12 +42,18 @@ from xmodule.modulestore.inheritance import InheritanceMixin, inherit_metadata, ...@@ -41,12 +42,18 @@ from xmodule.modulestore.inheritance import InheritanceMixin, inherit_metadata,
from xblock.core import XBlock from xblock.core import XBlock
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import CourseLocator from opaque_keys.edx.locator import CourseLocator
from opaque_keys.edx.keys import UsageKey, CourseKey from opaque_keys.edx.keys import UsageKey, CourseKey, AssetKey
from xmodule.exceptions import HeartbeatFailure from xmodule.exceptions import HeartbeatFailure
from xmodule.modulestore.edit_info import EditInfoRuntimeMixin from xmodule.modulestore.edit_info import EditInfoRuntimeMixin
from xmodule.assetstore import AssetMetadata, AssetThumbnailMetadata
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
new_contract('CourseKey', CourseKey)
new_contract('AssetKey', AssetKey)
new_contract('AssetMetadata', AssetMetadata)
new_contract('AssetThumbnailMetadata', AssetThumbnailMetadata)
# sort order that returns DRAFT items first # sort order that returns DRAFT items first
SORT_REVISION_FAVOR_DRAFT = ('_id.revision', pymongo.DESCENDING) SORT_REVISION_FAVOR_DRAFT = ('_id.revision', pymongo.DESCENDING)
...@@ -195,7 +202,6 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): ...@@ -195,7 +202,6 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
category = json_data['location']['category'] category = json_data['location']['category']
class_ = self.load_block_type(category) class_ = self.load_block_type(category)
definition = json_data.get('definition', {}) definition = json_data.get('definition', {})
metadata = json_data.get('metadata', {}) metadata = json_data.get('metadata', {})
for old_name, new_name in getattr(class_, 'metadata_translations', {}).items(): for old_name, new_name in getattr(class_, 'metadata_translations', {}).items():
...@@ -443,7 +449,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo ...@@ -443,7 +449,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
super(MongoModuleStore, self).__init__(contentstore=contentstore, **kwargs) super(MongoModuleStore, self).__init__(contentstore=contentstore, **kwargs)
def do_connection( def do_connection(
db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs db, collection, host, port=27017, tz_aware=True, user=None, password=None, asset_collection=None, **kwargs
): ):
""" """
Create & open the connection, authenticate, and provide pointers to the collection Create & open the connection, authenticate, and provide pointers to the collection
...@@ -460,6 +466,11 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo ...@@ -460,6 +466,11 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
) )
self.collection = self.database[collection] self.collection = self.database[collection]
# Collection which stores asset metadata.
self.asset_collection = None
if asset_collection is not None:
self.asset_collection = self.database[asset_collection]
if user is not None and password is not None: if user is not None and password is not None:
self.database.authenticate(user, password) self.database.authenticate(user, password)
...@@ -1436,6 +1447,147 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo ...@@ -1436,6 +1447,147 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
field_data = KvsFieldData(kvs) field_data = KvsFieldData(kvs)
return field_data return field_data
def _find_course_assets(self, course_key):
"""
Internal; finds (or creates) course asset info about all assets for a particular course
Arguments:
course_key (CourseKey): course identifier
Returns:
Asset info for the course
"""
if self.asset_collection is None:
return None
# Using the course_key, find or insert the course asset metadata document.
# A single document exists per course to store the course asset metadata.
course_assets = self.asset_collection.find_one(
{'course_id': unicode(course_key)},
fields=('course_id', 'storage', 'assets', 'thumbnails')
)
if course_assets is None:
# Not found, so create.
course_assets = {'course_id': unicode(course_key), 'storage': 'FILLMEIN-TMP', 'assets': [], 'thumbnails': []}
course_assets['_id'] = self.asset_collection.insert(course_assets)
return course_assets
@contract(course_key='CourseKey', asset_metadata='AssetMetadata | AssetThumbnailMetadata', user_id='str | unicode')
def _save_asset_info(self, course_key, asset_metadata, user_id, thumbnail=False):
"""
Saves the info for a particular course's asset/thumbnail.
Arguments:
course_key (CourseKey): course identifier
asset_metadata (AssetMetadata/AssetThumbnailMetadata): data about the course asset/thumbnail
thumbnail (bool): True if saving thumbnail metadata, False if saving asset metadata
Returns:
True if info save was successful, else False
"""
if self.asset_collection is None:
return False
course_assets, asset_idx = self._find_course_asset(course_key, asset_metadata.asset_id.path, thumbnail)
info = 'thumbnails' if thumbnail else 'assets'
all_assets = course_assets[info]
# Set the edited information for assets only - not thumbnails.
if not thumbnail:
asset_metadata.update({'edited_by': user_id, 'edited_on': datetime.now(UTC)})
# Translate metadata to Mongo format.
metadata_to_insert = asset_metadata.to_mongo()
if asset_idx is None:
# Append new metadata.
# Future optimization: Insert in order & binary search to retrieve.
all_assets.append(metadata_to_insert)
else:
# Replace existing metadata.
all_assets[asset_idx] = metadata_to_insert
# Update the document.
self.asset_collection.update({'_id': course_assets['_id']}, {'$set': {info: all_assets}})
return True
@contract(asset_key='AssetKey', attr_dict=dict)
def set_asset_metadata_attrs(self, asset_key, attr_dict, user_id):
"""
Add/set the given dict of attrs on the asset at the given location. Value can be any type which pymongo accepts.
Arguments:
asset_key (AssetKey): asset identifier
attr_dict (dict): attribute: value pairs to set
Raises:
ItemNotFoundError if no such item exists
AttributeError is attr is one of the build in attrs.
"""
if self.asset_collection is None:
return
course_assets, asset_idx = self._find_course_asset(asset_key.course_key, asset_key.path)
if asset_idx is None:
raise ItemNotFoundError(asset_key)
# Form an AssetMetadata.
all_assets = course_assets['assets']
md = AssetMetadata(asset_key, asset_key.path)
md.from_mongo(all_assets[asset_idx])
md.update(attr_dict)
md.update({'edited_by': user_id, 'edited_on': datetime.now(UTC)})
# Generate a Mongo doc from the metadata and update the course asset info.
all_assets[asset_idx] = md.to_mongo()
self.asset_collection.update({'_id': course_assets['_id']}, {"$set": {'assets': all_assets}})
@contract(asset_key='AssetKey')
def _delete_asset_data(self, asset_key, thumbnail=False):
"""
Internal; deletes a single asset's metadata -or- thumbnail.
Arguments:
asset_key (AssetKey): key containing original asset/thumbnail filename
thumbnail: True if thumbnail deletion, False if asset metadata deletion
Returns:
Number of asset metadata/thumbnail entries deleted (0 or 1)
"""
if self.asset_collection is None:
return 0
course_assets, asset_idx = self._find_course_asset(asset_key.course_key, asset_key.path, get_thumbnail=thumbnail)
if asset_idx is None:
return 0
info = 'thumbnails' if thumbnail else 'assets'
all_asset_info = course_assets[info]
all_asset_info.pop(asset_idx)
# Update the document.
self.asset_collection.update({'_id': course_assets['_id']}, {'$set': {info: all_asset_info}})
return 1
@contract(course_key='CourseKey')
def delete_all_asset_metadata(self, course_key):
"""
Delete all of the assets which use this course_key as an identifier.
Arguments:
course_key (CourseKey): course_identifier
"""
if self.asset_collection is None:
return
# Using the course_id, find the course asset metadata document.
# A single document exists per course to store the course asset metadata.
course_assets = self._find_course_assets(course_key)
self.asset_collection.remove(course_assets['_id'])
def heartbeat(self): def heartbeat(self):
""" """
Check that the db is reachable. Check that the db is reachable.
......
...@@ -99,7 +99,7 @@ class MongoConnection(object): ...@@ -99,7 +99,7 @@ class MongoConnection(object):
Segregation of pymongo functions from the data modeling mechanisms for split modulestore. Segregation of pymongo functions from the data modeling mechanisms for split modulestore.
""" """
def __init__( def __init__(
self, db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs self, db, collection, host, port=27017, tz_aware=True, user=None, password=None, asset_collection=None, **kwargs
): ):
""" """
Create & open the connection, authenticate, and provide pointers to the collections Create & open the connection, authenticate, and provide pointers to the collections
...@@ -114,6 +114,10 @@ class MongoConnection(object): ...@@ -114,6 +114,10 @@ class MongoConnection(object):
db db
) )
# Remove when adding official Split support for asset metadata storage.
if asset_collection:
pass
if user is not None and password is not None: if user is not None and password is not None:
self.database.authenticate(user, password) self.database.authenticate(user, password)
......
...@@ -47,6 +47,7 @@ class TestMixedModuleStore(CourseComparisonTest): ...@@ -47,6 +47,7 @@ class TestMixedModuleStore(CourseComparisonTest):
PORT = MONGO_PORT_NUM PORT = MONGO_PORT_NUM
DB = 'test_mongo_%s' % uuid4().hex[:5] DB = 'test_mongo_%s' % uuid4().hex[:5]
COLLECTION = 'modulestore' COLLECTION = 'modulestore'
ASSET_COLLECTION = 'assetstore'
FS_ROOT = DATA_DIR FS_ROOT = DATA_DIR
DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor' DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor'
RENDER_TEMPLATE = lambda t_n, d, ctx = None, nsp = 'main': '' RENDER_TEMPLATE = lambda t_n, d, ctx = None, nsp = 'main': ''
...@@ -67,6 +68,7 @@ class TestMixedModuleStore(CourseComparisonTest): ...@@ -67,6 +68,7 @@ class TestMixedModuleStore(CourseComparisonTest):
'port': PORT, 'port': PORT,
'db': DB, 'db': DB,
'collection': COLLECTION, 'collection': COLLECTION,
'asset_collection': ASSET_COLLECTION,
} }
OPTIONS = { OPTIONS = {
'mappings': { 'mappings': {
......
...@@ -14,6 +14,7 @@ from datetime import datetime ...@@ -14,6 +14,7 @@ from datetime import datetime
from pytz import UTC from pytz import UTC
import unittest import unittest
from xblock.core import XBlock from xblock.core import XBlock
from ddt import ddt, data
from xblock.fields import Scope, Reference, ReferenceList, ReferenceValueDict from xblock.fields import Scope, Reference, ReferenceList, ReferenceValueDict
from xblock.runtime import KeyValueStore from xblock.runtime import KeyValueStore
...@@ -30,6 +31,7 @@ from opaque_keys.edx.keys import UsageKey ...@@ -30,6 +31,7 @@ from opaque_keys.edx.keys import UsageKey
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
from xmodule.contentstore.mongo import MongoContentStore from xmodule.contentstore.mongo import MongoContentStore
from xmodule.assetstore import AssetMetadata, AssetThumbnailMetadata
from nose.tools import assert_in from nose.tools import assert_in
from xmodule.exceptions import NotFoundError from xmodule.exceptions import NotFoundError
...@@ -45,6 +47,7 @@ HOST = MONGO_HOST ...@@ -45,6 +47,7 @@ HOST = MONGO_HOST
PORT = MONGO_PORT_NUM PORT = MONGO_PORT_NUM
DB = 'test_mongo_%s' % uuid4().hex[:5] DB = 'test_mongo_%s' % uuid4().hex[:5]
COLLECTION = 'modulestore' COLLECTION = 'modulestore'
ASSET_COLLECTION = 'assetstore'
FS_ROOT = DATA_DIR # TODO (vshnayder): will need a real fs_root for testing load_item FS_ROOT = DATA_DIR # TODO (vshnayder): will need a real fs_root for testing load_item
DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor' DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor'
RENDER_TEMPLATE = lambda t_n, d, ctx = None, nsp = 'main': '' RENDER_TEMPLATE = lambda t_n, d, ctx = None, nsp = 'main': ''
...@@ -60,8 +63,10 @@ class ReferenceTestXBlock(XBlock, XModuleMixin): ...@@ -60,8 +63,10 @@ class ReferenceTestXBlock(XBlock, XModuleMixin):
reference_dict = ReferenceValueDict(scope=Scope.settings) reference_dict = ReferenceValueDict(scope=Scope.settings)
class TestMongoModuleStore(unittest.TestCase): class TestMongoModuleStoreBase(unittest.TestCase):
'''Tests!''' '''
Basic setup for all tests
'''
# Explicitly list the courses to load (don't want the big one) # Explicitly list the courses to load (don't want the big one)
courses = ['toy', 'simple', 'simple_with_draft', 'test_unicode'] courses = ['toy', 'simple', 'simple_with_draft', 'test_unicode']
...@@ -88,6 +93,13 @@ class TestMongoModuleStore(unittest.TestCase): ...@@ -88,6 +93,13 @@ class TestMongoModuleStore(unittest.TestCase):
cls.connection.close() cls.connection.close()
@classmethod @classmethod
def add_asset_collection(cls, doc_store_config):
"""
No asset collection.
"""
pass
@classmethod
def initdb(cls): def initdb(cls):
# connect to the db # connect to the db
doc_store_config = { doc_store_config = {
...@@ -95,7 +107,10 @@ class TestMongoModuleStore(unittest.TestCase): ...@@ -95,7 +107,10 @@ class TestMongoModuleStore(unittest.TestCase):
'port': PORT, 'port': PORT,
'db': DB, 'db': DB,
'collection': COLLECTION, 'collection': COLLECTION,
#'asset_collection': ASSET_COLLECTION,
} }
cls.add_asset_collection(doc_store_config)
# since MongoModuleStore and MongoContentStore are basically assumed to be together, create this class # since MongoModuleStore and MongoContentStore are basically assumed to be together, create this class
# as well # as well
content_store = MongoContentStore(HOST, DB, port=PORT) content_store = MongoContentStore(HOST, DB, port=PORT)
...@@ -136,14 +151,33 @@ class TestMongoModuleStore(unittest.TestCase): ...@@ -136,14 +151,33 @@ class TestMongoModuleStore(unittest.TestCase):
# Destroy the test db. # Destroy the test db.
connection.drop_database(DB) connection.drop_database(DB)
def setUp(self): @classmethod
# make a copy for convenience def setUp(cls):
self.connection = TestMongoModuleStore.connection cls.dummy_user = ModuleStoreEnum.UserID.test
self.dummy_user = ModuleStoreEnum.UserID.test
def tearDown(self): @classmethod
def tearDown(cls):
pass pass
class TestMongoModuleStore(TestMongoModuleStoreBase):
'''Module store tests'''
@classmethod
def add_asset_collection(cls, doc_store_config):
"""
No asset collection - it's not used in the tests below.
"""
pass
@classmethod
def setupClass(cls):
super(TestMongoModuleStore, cls).setupClass()
@classmethod
def teardownClass(cls):
super(TestMongoModuleStore, cls).teardownClass()
def test_init(self): def test_init(self):
'''Make sure the db loads''' '''Make sure the db loads'''
ids = list(self.connection[DB][COLLECTION].find({}, {'_id': True})) ids = list(self.connection[DB][COLLECTION].find({}, {'_id': True}))
...@@ -233,7 +267,6 @@ class TestMongoModuleStore(unittest.TestCase): ...@@ -233,7 +267,6 @@ class TestMongoModuleStore(unittest.TestCase):
self.draft_store.get_item(Location('edX', 'test_unicode', '2012_Fall', 'chapter', 'Overview')), self.draft_store.get_item(Location('edX', 'test_unicode', '2012_Fall', 'chapter', 'Overview')),
) )
def test_find_one(self): def test_find_one(self):
assert_not_none( assert_not_none(
self.draft_store._find_one(Location('edX', 'toy', '2012_Fall', 'course', '2012_Fall')), self.draft_store._find_one(Location('edX', 'toy', '2012_Fall', 'course', '2012_Fall')),
...@@ -632,6 +665,363 @@ class TestMongoModuleStore(unittest.TestCase): ...@@ -632,6 +665,363 @@ class TestMongoModuleStore(unittest.TestCase):
shutil.rmtree(root_dir) shutil.rmtree(root_dir)
@ddt
class TestMongoAssetMetadataStorage(TestMongoModuleStore):
"""
Tests for storing/querying course asset metadata from Mongo storage.
"""
def _make_asset_metadata(self, asset_loc):
"""
Make a single test asset metadata.
"""
return AssetMetadata(asset_loc, internal_name='EKMND332DDBK',
basename='pictures/historical', contenttype='image/jpeg',
locked=False, md5='77631ca4f0e08419b70726a447333ab6',
curr_version='v1.0', prev_version='v0.95')
def _make_asset_thumbnail_metadata(self, asset_key):
"""
Make a single test asset thumbnail metadata.
"""
return AssetThumbnailMetadata(asset_key, internal_name='ABC39XJUDN2')
@classmethod
def add_asset_collection(cls, doc_store_config):
"""
Valid asset collection.
"""
doc_store_config['asset_collection'] = ASSET_COLLECTION
@classmethod
def setupClass(cls):
super(TestMongoAssetMetadataStorage, cls).setupClass()
@classmethod
def teardownClass(cls):
super(TestMongoAssetMetadataStorage, cls).teardownClass()
def setup_assets(self):
"""
Setup assets.
"""
asset_fields = ('filename', 'internal_name', 'basename', 'locked', 'curr_version', 'prev_version')
asset1_vals = ('pic1.jpg', 'EKMND332DDBK', 'pix/archive', False, '14', '13')
asset2_vals = ('shout.ogg', 'KFMDONSKF39K', 'sounds', True, '1', None)
asset3_vals = ('code.tgz', 'ZZB2333YBDMW', 'exercises/14', False, 'AB', 'AA')
asset4_vals = ('dog.png', 'PUPY4242X', 'pictures/animals', True, '5', '4')
asset5_vals = ('not_here.txt', 'JJJCCC747', '/dev/null', False, '50', '49')
asset1 = dict(zip(asset_fields[1:], asset1_vals[1:]))
asset2 = dict(zip(asset_fields[1:], asset2_vals[1:]))
asset3 = dict(zip(asset_fields[1:], asset3_vals[1:]))
asset4 = dict(zip(asset_fields[1:], asset4_vals[1:]))
non_existent_asset = dict(zip(asset_fields[1:], asset5_vals[1:]))
# Asset6 and thumbnail6 have equivalent information on purpose.
asset6_vals = ('asset.txt', 'JJJCCC747858', '/dev/null', False, '50', '49')
asset6 = dict(zip(asset_fields[1:], asset6_vals[1:]))
asset1_key = self.course1.id.make_asset_key('asset', asset1_vals[0])
asset2_key = self.course1.id.make_asset_key('asset', asset2_vals[0])
asset3_key = self.course2.id.make_asset_key('asset', asset3_vals[0])
asset4_key = self.course2.id.make_asset_key('asset', asset4_vals[0])
asset5_key = self.course2.id.make_asset_key('asset', asset5_vals[0])
asset6_key = self.course2.id.make_asset_key('asset', asset6_vals[0])
asset1_md = AssetMetadata(asset1_key, **asset1)
asset2_md = AssetMetadata(asset2_key, **asset2)
asset3_md = AssetMetadata(asset3_key, **asset3)
asset4_md = AssetMetadata(asset4_key, **asset4)
asset5_md = AssetMetadata(asset5_key, **non_existent_asset)
asset6_md = AssetMetadata(asset6_key, **asset6)
editing_user = 'Oliver Twist'
self.assertTrue(self.draft_store.save_asset_metadata(self.course1.id, asset1_md, editing_user))
self.assertTrue(self.draft_store.save_asset_metadata(self.course1.id, asset2_md, editing_user))
self.assertTrue(self.draft_store.save_asset_metadata(self.course2.id, asset3_md, editing_user))
self.assertTrue(self.draft_store.save_asset_metadata(self.course2.id, asset4_md, editing_user))
# asset5 and asset6 are not saved on purpose!
return (asset1_md, asset2_md, asset3_md, asset4_md, asset5_md, asset6_md)
def setup_thumbnails(self):
"""
Setup thumbs.
"""
thumbnail_fields = ('filename', 'internal_name')
thumbnail1_vals = ('cat_thumb.jpg', 'XYXYXYXYXYXY')
thumbnail2_vals = ('kitten_thumb.jpg', '123ABC123ABC')
thumbnail3_vals = ('puppy_thumb.jpg', 'ADAM12ADAM12')
thumbnail4_vals = ('meerkat_thumb.jpg', 'CHIPSPONCH14')
thumbnail5_vals = ('corgi_thumb.jpg', 'RON8LDXFFFF10')
thumbnail1 = dict(zip(thumbnail_fields[1:], thumbnail1_vals[1:]))
thumbnail2 = dict(zip(thumbnail_fields[1:], thumbnail2_vals[1:]))
thumbnail3 = dict(zip(thumbnail_fields[1:], thumbnail3_vals[1:]))
thumbnail4 = dict(zip(thumbnail_fields[1:], thumbnail4_vals[1:]))
non_existent_thumbnail = dict(zip(thumbnail_fields[1:], thumbnail5_vals[1:]))
# Asset6 and thumbnail6 have equivalent information on purpose.
thumbnail6_vals = ('asset.txt', 'JJJCCC747858')
thumbnail6 = dict(zip(thumbnail_fields[1:], thumbnail6_vals[1:]))
thumb1_key = self.course1.id.make_asset_key('thumbnail', thumbnail1_vals[0])
thumb2_key = self.course1.id.make_asset_key('thumbnail', thumbnail2_vals[0])
thumb3_key = self.course2.id.make_asset_key('thumbnail', thumbnail3_vals[0])
thumb4_key = self.course2.id.make_asset_key('thumbnail', thumbnail4_vals[0])
thumb5_key = self.course2.id.make_asset_key('thumbnail', thumbnail5_vals[0])
thumb6_key = self.course2.id.make_asset_key('thumbnail', thumbnail6_vals[0])
thumb1_md = AssetThumbnailMetadata(thumb1_key, **thumbnail1)
thumb2_md = AssetThumbnailMetadata(thumb2_key, **thumbnail2)
thumb3_md = AssetThumbnailMetadata(thumb3_key, **thumbnail3)
thumb4_md = AssetThumbnailMetadata(thumb4_key, **thumbnail4)
thumb5_md = AssetThumbnailMetadata(thumb5_key, **non_existent_thumbnail)
thumb6_md = AssetThumbnailMetadata(thumb6_key, **thumbnail6)
self.assertTrue(self.draft_store.save_asset_thumbnail_metadata(self.course1.id, thumb1_md))
self.assertTrue(self.draft_store.save_asset_thumbnail_metadata(self.course1.id, thumb2_md))
self.assertTrue(self.draft_store.save_asset_thumbnail_metadata(self.course2.id, thumb3_md))
self.assertTrue(self.draft_store.save_asset_thumbnail_metadata(self.course2.id, thumb4_md))
# thumb5 and thumb6 are not saved on purpose!
return (thumb1_md, thumb2_md, thumb3_md, thumb4_md, thumb5_md, thumb6_md)
def setUp(self):
"""
Set up a quantity of test asset metadata for testing purposes.
"""
super(TestMongoAssetMetadataStorage, self).setUp()
courses = self.draft_store.get_courses()
self.course1 = courses[0]
self.course2 = courses[1]
(self.asset1_md, self.asset2_md, self.asset3_md, self.asset4_md, self.asset5_md, self.asset6_md) = self.setup_assets()
(self.thumb1_md, self.thumb2_md, self.thumb3_md, self.thumb4_md, self.thumb5_md, self.thumb6_md) = self.setup_thumbnails()
def tearDown(self):
self.draft_store.delete_all_asset_metadata(self.course1.id)
self.draft_store.delete_all_asset_metadata(self.course2.id)
def test_save_one_and_confirm(self):
courses = self.draft_store.get_courses()
course = courses[0]
asset_filename = 'burnside.jpg'
new_asset_loc = course.id.make_asset_key('asset', asset_filename)
# Confirm that the asset's metadata is not present.
self.assertIsNone(self.draft_store.find_asset_metadata(new_asset_loc))
# Save the asset's metadata.
new_asset_md = self._make_asset_metadata(new_asset_loc)
self.assertTrue(self.draft_store.save_asset_metadata(course.id, new_asset_md, 'John Doe'))
# Find the asset's metadata and confirm it's the same.
found_asset_md = self.draft_store.find_asset_metadata(new_asset_loc)
self.assertIsNotNone(found_asset_md)
self.assertEquals(new_asset_md.asset_id, found_asset_md.asset_id)
# Confirm that only two setup plus one asset's metadata exists.
self.assertEquals(len(self.draft_store.get_all_asset_metadata(course.id)), 3)
# Delete all metadata and confirm it's gone.
self.draft_store.delete_all_asset_metadata(course.id)
self.assertEquals(len(self.draft_store.get_all_asset_metadata(course.id)), 0)
def test_delete_all_without_creation(self):
courses = self.draft_store.get_courses()
course = courses[0]
# Confirm that only setup asset metadata exists.
self.assertEquals(len(self.draft_store.get_all_asset_metadata(course.id)), 2)
# Now delete the metadata.
self.draft_store.delete_all_asset_metadata(course.id)
self.assertEquals(len(self.draft_store.get_all_asset_metadata(course.id)), 0)
# Now delete the non-existent metadata.
self.draft_store.delete_all_asset_metadata(course.id)
self.assertEquals(len(self.draft_store.get_all_asset_metadata(course.id)), 0)
def test_save_many_and_delete_one(self):
# Make sure there's two assets.
self.assertEquals(len(self.draft_store.get_all_asset_metadata(self.course1.id)), 2)
# Delete one of the assets.
self.assertEquals(self.draft_store.delete_asset_metadata(self.asset1_md.asset_id), 1)
self.assertEquals(len(self.draft_store.get_all_asset_metadata(self.course1.id)), 1)
# Attempt to delete an asset that doesn't exist.
self.assertEquals(self.draft_store.delete_asset_metadata(self.asset5_md.asset_id), 0)
self.assertEquals(len(self.draft_store.get_all_asset_metadata(self.course1.id)), 1)
def test_find_existing_and_non_existing_assets(self):
# Find existing asset metadata.
asset_md = self.draft_store.find_asset_metadata(self.asset1_md.asset_id)
self.assertIsNotNone(asset_md)
# Find non-existent asset metadata.
asset_md = self.draft_store.find_asset_metadata(self.asset5_md.asset_id)
self.assertIsNone(asset_md)
def test_add_same_asset_twice(self):
courses = self.draft_store.get_courses()
course = courses[0]
asset_filename = 'burnside.jpg'
new_asset_loc = course.id.make_asset_key('asset', asset_filename)
new_asset_md = self._make_asset_metadata(new_asset_loc)
# Only the setup stuff here?
self.assertEquals(len(self.draft_store.get_all_asset_metadata(course.id)), 2)
# Add asset metadata.
self.assertTrue(self.draft_store.save_asset_metadata(course.id, new_asset_md, "John Do"))
self.assertEquals(len(self.draft_store.get_all_asset_metadata(course.id)), 3)
# Add *the same* asset metadata.
self.assertTrue(self.draft_store.save_asset_metadata(course.id, new_asset_md, "John Dont"))
# Still one here?
self.assertEquals(len(self.draft_store.get_all_asset_metadata(course.id)), 3)
self.draft_store.delete_all_asset_metadata(course.id)
self.assertEquals(len(self.draft_store.get_all_asset_metadata(course.id)), 0)
def test_lock_unlock_assets(self):
# Find a course asset and check its locked status.
asset_md = self.draft_store.find_asset_metadata(self.asset1_md.asset_id)
self.assertIsNotNone(asset_md)
locked_state = asset_md.locked
# Flip the course asset's locked status.
self.draft_store.set_asset_metadata_attr(self.asset1_md.asset_id, "locked", not locked_state, 'John Doe')
# Find the same course and check its locked status.
updated_asset_md = self.draft_store.find_asset_metadata(self.asset1_md.asset_id)
self.assertIsNotNone(updated_asset_md)
self.assertEquals(updated_asset_md.locked, not locked_state)
# Now flip it back.
self.draft_store.set_asset_metadata_attr(self.asset1_md.asset_id, "locked", locked_state, 'John Doe')
reupdated_asset_md = self.draft_store.find_asset_metadata(self.asset1_md.asset_id)
self.assertIsNotNone(reupdated_asset_md)
self.assertEquals(reupdated_asset_md.locked, locked_state)
ALLOWED_ATTRS = (
('basename', '/new/path'),
('internal_name', 'new_filename.txt'),
('locked', True),
('contenttype', 'image/png'),
('md5', '5346682d948cc3f683635b6918f9b3d0'),
('curr_version', 'v1.01'),
('prev_version', 'v1.0'),
('edited_by', 'Mork'),
('edited_on', datetime(1969, 1, 1, tzinfo=UTC)),
)
DISALLOWED_ATTRS = (
('asset_id', 'IAmBogus'),
)
UNKNOWN_ATTRS = (
('lunch_order', 'burger_and_fries'),
('villain', 'Khan')
)
@data(*ALLOWED_ATTRS)
def test_set_all_attrs(self, attr_pair):
# Find a course asset.
asset_md = self.draft_store.find_asset_metadata(self.asset1_md.asset_id)
self.assertIsNotNone(asset_md)
# Set the course asset's attr.
editing_user = 'user_who_edited'
self.draft_store.set_asset_metadata_attr(self.asset1_md.asset_id, *attr_pair, user_id=editing_user)
# Find the same course asset and check its changed attr.
updated_asset_md = self.draft_store.find_asset_metadata(self.asset1_md.asset_id)
self.assertIsNotNone(updated_asset_md)
self.assertIsNotNone(getattr(updated_asset_md, attr_pair[0], None))
if attr_pair[0] == 'edited_by':
# No matter what the edited_by attr_pair is, it gets over-ridden by the passed-in user_id.
self.assertEquals(getattr(updated_asset_md, attr_pair[0], None), editing_user)
elif attr_pair[0] == 'edited_on':
# edited_on is also over-ridden to be the time of update.
pass
else:
self.assertEquals(getattr(updated_asset_md, attr_pair[0], None), attr_pair[1])
@data(*DISALLOWED_ATTRS)
def test_set_disallowed_attrs(self, attr_pair):
# Find a course asset.
asset_md = self.draft_store.find_asset_metadata(self.asset1_md.asset_id)
self.assertIsNotNone(asset_md)
original_attr_val = getattr(asset_md, attr_pair[0])
# Set the course asset's attr.
self.draft_store.set_asset_metadata_attr(self.asset1_md.asset_id, *attr_pair, user_id='John Doe')
# Find the same course and check its changed attr.
updated_asset_md = self.draft_store.find_asset_metadata(self.asset1_md.asset_id)
self.assertIsNotNone(updated_asset_md)
self.assertIsNotNone(getattr(updated_asset_md, attr_pair[0], None))
# Make sure that the attr is unchanged from its original value.
self.assertEquals(getattr(updated_asset_md, attr_pair[0], None), original_attr_val)
@data(*UNKNOWN_ATTRS)
def test_set_unknown_attrs(self, attr_pair):
# Find a course asset.
asset_md = self.draft_store.find_asset_metadata(self.asset1_md.asset_id)
self.assertIsNotNone(asset_md)
# Set the course asset's attr.
self.draft_store.set_asset_metadata_attr(self.asset1_md.asset_id, *attr_pair, user_id='John Smith')
# Find the same course and check its changed attr.
updated_asset_md = self.draft_store.find_asset_metadata(self.asset1_md.asset_id)
self.assertIsNotNone(updated_asset_md)
# Make sure the unknown field was *not* added.
with self.assertRaises(AttributeError):
self.assertEquals(getattr(updated_asset_md, attr_pair[0]), attr_pair[1])
def test_save_one_thumbnail_and_delete_one_thumbnail(self):
thumbnail_filename = 'burn_thumb.jpg'
asset_key = self.course1.id.make_asset_key('thumbnail', thumbnail_filename)
new_asset_thumbnail = self._make_asset_thumbnail_metadata(asset_key)
self.assertEquals(len(self.draft_store.get_all_asset_thumbnail_metadata(self.course1.id)), 2)
self.assertTrue(self.draft_store.save_asset_thumbnail_metadata(self.course1.id, new_asset_thumbnail))
self.assertEquals(len(self.draft_store.get_all_asset_thumbnail_metadata(self.course1.id)), 3)
self.assertEquals(self.draft_store.delete_asset_thumbnail_metadata(asset_key), 1)
self.assertEquals(len(self.draft_store.get_all_asset_thumbnail_metadata(self.course1.id)), 2)
def test_find_thumbnail(self):
self.assertIsNotNone(self.draft_store.find_asset_thumbnail_metadata(self.thumb1_md.asset_id))
self.assertIsNone(self.draft_store.find_asset_thumbnail_metadata(self.thumb5_md.asset_id))
def test_delete_all_thumbnails(self):
self.assertEquals(len(self.draft_store.get_all_asset_thumbnail_metadata(self.course1.id)), 2)
self.draft_store.delete_all_asset_metadata(self.course1.id)
self.assertEquals(len(self.draft_store.get_all_asset_thumbnail_metadata(self.course1.id)), 0)
def test_asset_object_equivalence(self):
# Assets are only equivalent to themselves.
self.assertTrue(self.asset6_md != self.thumb6_md)
self.assertEquals(self.asset1_md, self.asset1_md)
def test_get_all_assets_with_paging(self):
pass
def test_copy_all_assets(self):
pass
class TestMongoModuleStoreWithNoAssetCollection(TestMongoModuleStore):
'''
Tests a situation where no asset_collection is specified.
'''
@classmethod
def add_asset_collection(cls, doc_store_config):
"""
No asset collection.
"""
pass
@classmethod
def setupClass(cls):
super(TestMongoModuleStoreWithNoAssetCollection, cls).setupClass()
@classmethod
def teardownClass(cls):
super(TestMongoModuleStoreWithNoAssetCollection, cls).teardownClass()
def test_no_asset_collection(self):
courses = self.draft_store.get_courses()
course = courses[0]
# Confirm that no asset collection means no asset metadata.
self.assertEquals(self.draft_store.get_all_asset_metadata(course.id), None)
# Now delete the non-existent asset metadata.
self.draft_store.delete_all_asset_metadata(course.id)
# Should still be nothing.
self.assertEquals(self.draft_store.get_all_asset_metadata(course.id), None)
class TestMongoKeyValueStore(object): class TestMongoKeyValueStore(object):
""" """
Tests for MongoKeyValueStore. Tests for MongoKeyValueStore.
......
...@@ -566,6 +566,7 @@ DOC_STORE_CONFIG = { ...@@ -566,6 +566,7 @@ DOC_STORE_CONFIG = {
'host': 'localhost', 'host': 'localhost',
'db': 'xmodule', 'db': 'xmodule',
'collection': 'modulestore', 'collection': 'modulestore',
'asset_collection': 'assetstore',
} }
MODULESTORE = { MODULESTORE = {
'default': { 'default': {
......
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