class KeyAuthContext(StoredObject, AuthContext): _id = fields.StringField(primary=True) can_provision = fields.BooleanField(default=False) can_create_repos = fields.BooleanField(default=False) full_name = fields.StringField() email = fields.StringField() def can_read_repo(self, repo_id): for field in ['admin_repos', 'read_repos', 'write_repos']: try: for ref in getattr(self, field).get('repometa', []): if repo_id in getattr(self, field)['repometa'][ref]: return True except AttributeError: pass return RepoMeta.load(repo_id).is_public def can_write_repo(self, repo_id): for field in ['admin_repos', 'write_repos']: try: for ref in getattr(self, field).get('repometa', []): if repo_id in getattr(self, field)['repometa'][ref]: return True except AttributeError: pass return False def __init__(self, *args, **kwargs): super(KeyAuthContext, self).__init__(*args, **kwargs) self._id = sha( str(SystemRandom().random()) ).hexdigest()
class Conference(StoredObject): #: Determines the email address for submission and the OSF url # Example: If endpoint is spsp2014, then submission email will be # [email protected] or [email protected] and the OSF url will # be osf.io/view/spsp2014 endpoint = fields.StringField(primary=True, required=True, unique=True) #: Full name, e.g. "SPSP 2014" name = fields.StringField(required=True) info_url = fields.StringField(required=False, default=None) logo_url = fields.StringField(required=False, default=None) location = fields.StringField(required=False, default=None) start_date = fields.DateTimeField(default=None) end_date = fields.DateTimeField(default=None) active = fields.BooleanField(required=True) admins = fields.ForeignField('user', list=True, required=False, default=None) #: Whether to make submitted projects public public_projects = fields.BooleanField(required=False, default=True) poster = fields.BooleanField(default=True) talk = fields.BooleanField(default=True) # field_names are used to customize the text on the conference page, the categories # of submissions, and the email adress to send material to. field_names = fields.DictionaryField(default=lambda: DEFAULT_FIELD_NAMES) # Cached number of submissions num_submissions = fields.IntegerField(default=0) @classmethod def get_by_endpoint(cls, endpoint, active=True): query = Q('endpoint', 'iexact', endpoint) if active: query &= Q('active', 'eq', True) try: return Conference.find_one(query) except ModularOdmException: raise ConferenceError('Endpoint {0} not found'.format(endpoint))
class Conference(StoredObject): #: Determines the email address for submission and the OSF url # Example: If endpoint is spsp2014, then submission email will be # [email protected] or [email protected] and the OSF url will # be osf.io/view/spsp2014 endpoint = fields.StringField(primary=True, required=True, unique=True) #: Full name, e.g. "SPSP 2014" name = fields.StringField(required=True) info_url = fields.StringField(required=False, default=None) logo_url = fields.StringField(required=False, default=None) active = fields.BooleanField(required=True) admins = fields.ForeignField('user', list=True, required=False, default=None) #: Whether to make submitted projects public public_projects = fields.BooleanField(required=False, default=True) poster = fields.BooleanField(default=True) talk = fields.BooleanField(default=True) @classmethod def get_by_endpoint(cls, endpoint, active=True): query = Q('endpoint', 'iexact', endpoint) if active: query &= Q('active', 'eq', True) try: return Conference.find_one(query) except ModularOdmException: raise ConferenceError('Endpoint {0} not found'.format(endpoint))
class Conference(StoredObject): #: Determines the email address for submission and the OSF url # Example: If endpoint is spsp2014, then submission email will be # [email protected] or [email protected] and the OSF url will # be osf.io/view/spsp2014 endpoint = fields.StringField(primary=True, required=True, unique=True) #: Full name, e.g. "SPSP 2014" name = fields.StringField(required=True) info_url = fields.StringField(required=False, default=None) logo_url = fields.StringField(required=False, default=None) active = fields.BooleanField(required=True) admins = fields.ForeignField('user', list=True, required=False, default=None) #: Whether to make submitted projects public public_projects = fields.BooleanField(required=False, default=True) poster = fields.BooleanField(default=True) talk = fields.BooleanField(default=True) # field_names are used to customize the text on the conference page, the categories # of submissions, and the email adress to send material to. field_names = fields.DictionaryField( default=lambda: { 'submission1': 'poster', 'submission2': 'talk', 'submission1_plural': 'posters', 'submission2_plural': 'talks', 'meeting_title_type': 'Posters & Talks', 'add_submission': 'poster or talk', 'mail_subject': 'Presentation title', 'mail_message_body': 'Presentation abstract (if any)', 'mail_attachment': 'Your presentation file (e.g., PowerPoint, PDF, etc.)' }) # Cached number of submissions num_submissions = fields.IntegerField(default=0) @classmethod def get_by_endpoint(cls, endpoint, active=True): query = Q('endpoint', 'iexact', endpoint) if active: query &= Q('active', 'eq', True) try: return Conference.find_one(query) except ModularOdmException: raise ConferenceError('Endpoint {0} not found'.format(endpoint))
class OsfStorageFileNode(StoredObject): _id = fields.StringField(primary=True, default=lambda: str(bson.ObjectId())) is_deleted = fields.BooleanField(default=False) name = fields.StringField(required=True, index=True) kind = fields.StringField(required=True, index=True) parent = fields.ForeignField('OsfStorageFileNode', index=True) versions = fields.ForeignField('OsfStorageFileVersion', list=True) node_settings = fields.ForeignField('OsfStorageNodeSettings', required=True, index=True) def materialized_path(self): def lineage(): current = self while current: yield current current = current.parent path = os.path.join(*reversed([x.name for x in lineage()])) if self.kind == 'folder': return '/{}/'.format(path) return '/{}'.format(path) def append_file(self, name, save=True): assert self.kind == 'folder' child = OsfStorageFileNode(name=name, kind='file', parent=self, node_settings=self.node_settings) if save: child.save() return child def find_child_by_name(self, name): assert self.kind == 'folder' return self.__class__.find_one( Q('name', 'eq', name) & Q('kind', 'eq', 'file') & Q('parent', 'eq', self)) @property def path(self): return '/{}{}'.format(self._id, '/' if self.kind == 'folder' else '') def get_download_count(self, version=None): """ :param int version: Optional one-based version index """ parts = ['download', self.node_settings.owner._id, self._id] if version is not None: parts.append(version) page = ':'.join([format(part) for part in parts]) _, count = get_basic_counters(page) return count or 0
class OsfStorageGuidFile(GuidFile): """A reference back to a OsfStorageFileNode path is the "waterbutler path" as well as the path used to look up a filenode GuidFile.path == FileNode.path == '/' + FileNode._id """ provider = 'osfstorage' version_identifier = 'version' _path = fields.StringField(index=True) premigration_path = fields.StringField(index=True) path = fields.StringField(required=True, index=True) # Marker for invalid GUIDs that are associated with a node but not # part of a GUID's file tree, e.g. those generated by spiders _has_no_file_tree = fields.BooleanField(default=False) @classmethod def get_or_create(cls, node, path): try: return cls.find_one(Q('node', 'eq', node) & Q('path', 'eq', path)), False except NoResultsFound: # Create new new = cls(node=node, path=path) new.save() return new, True @property def waterbutler_path(self): return self.path @property def unique_identifier(self): return self._metadata_cache['extra']['version'] @property def file_url(self): return os.path.join('osfstorage', 'files', self.path.lstrip('/')) def get_download_path(self, version_idx): url = furl.furl('/{0}/'.format(self._id)) url.args.update({ 'action': 'download', 'version': version_idx, 'mode': 'render', }) return url.url @property def extra(self): if not self._metadata_cache: return {} return { 'fullPath': self._metadata_cache['extra']['fullPath'], }
class Conference(StoredObject): #: Determines the email address for submission and the OSF url # Example: If endpoint is spsp2014, then submission email will be # [email protected] or [email protected] and the OSF url will # be osf.io/view/spsp2014 endpoint = fields.StringField(primary=True, required=True, unique=True) #: Full name, e.g. "SPSP 2014" name = fields.StringField(required=True) info_url = fields.StringField(required=False, default=None) logo_url = fields.StringField(required=False, default=None) active = fields.BooleanField(required=True) admins = fields.ForeignField('user', list=True, required=False, default=None) #: Whether to make submitted projects public public_projects = fields.BooleanField(required=False, default=True)
class ApiOAuth2Scope(StoredObject): """ Store information about recognized OAuth2 scopes. Only scopes registered under this database model can be requested by third parties. """ _id = fields.StringField(primary=True, default=lambda: str(ObjectId())) name = fields.StringField(unique=True, required=True, index=True) description = fields.StringField(required=True) is_active = fields.BooleanField( default=True, index=True) # TODO: Add mechanism to deactivate a scope?
class TrashedFileNode(StoredObject): """The graveyard for all deleted FileNodes""" _id = fields.StringField(primary=True) last_touched = fields.DateTimeField() history = fields.DictionaryField(list=True) versions = fields.ForeignField('FileVersion', list=True) node = fields.ForeignField('node', required=True) parent = fields.AbstractForeignField(default=None) is_file = fields.BooleanField(default=True) provider = fields.StringField(required=True) name = fields.StringField(required=True) path = fields.StringField(required=True) materialized_path = fields.StringField(required=True) checkout = fields.AbstractForeignField('User') deleted_by = fields.AbstractForeignField('User') deleted_on = fields.DateTimeField(auto_now_add=True) tags = fields.ForeignField('Tag', list=True) @property def deep_url(self): """Allows deleted files to resolve to a view that will provide a nice error message and http.GONE """ return self.node.web_url_for('addon_deleted_file', trashed_id=self._id) def restore(self, recursive=True, parent=None): """Recreate a StoredFileNode from the data in this object Will re-point all guids and finally remove itself :raises KeyExistsException: """ data = self.to_storage() data.pop('deleted_on') data.pop('deleted_by') if parent: data['parent'] = parent._id elif data['parent']: # parent is an AbstractForeignField, so it gets stored as tuple data['parent'] = data['parent'][0] restored = FileNode.resolve_class(self.provider, int(self.is_file))(**data) if not restored.parent: raise ValueError('No parent to restore to') restored.save() if recursive: for child in TrashedFileNode.find(Q('parent', 'eq', self)): child.restore(recursive=recursive, parent=restored) TrashedFileNode.remove_one(self) return restored
class NodeWikiPage(GuidStoredObject): redirect_mode = 'redirect' _id = fields.StringField(primary=True) page_name = fields.StringField(validate=validate_page_name) version = fields.IntegerField() date = fields.DateTimeField(auto_now_add=datetime.datetime.utcnow) is_current = fields.BooleanField() content = fields.StringField(default='') user = fields.ForeignField('user') node = fields.ForeignField('node') @property def deep_url(self): return '{}wiki/{}/'.format(self.node.deep_url, self.page_name) @property def url(self): return '{}wiki/{}/'.format(self.node.url, self.page_name) def html(self, node): """The cleaned HTML of the page""" sanitized_content = render_content(self.content, node=node) try: return linkify( sanitized_content, [ nofollow, ], ) except TypeError: logger.warning('Returning unlinkified content.') return sanitized_content def raw_text(self, node): """ The raw text of the page, suitable for using in a test search""" return sanitize(self.html(node), tags=[], strip=True) def save(self, *args, **kwargs): rv = super(NodeWikiPage, self).save(*args, **kwargs) if self.node: self.node.update_search() return rv def rename(self, new_name, save=True): self.page_name = new_name if save: self.save() def to_json(self): return {}
class ForwardNodeSettings(AddonNodeSettingsBase): url = fields.StringField(validate=URLValidator()) label = fields.StringField(validate=sanitized) redirect_bool = fields.BooleanField(default=True, validate=True) redirect_secs = fields.IntegerField( default=15, validate=[MinValueValidator(5), MaxValueValidator(60)] ) @property def link_text(self): return self.label if self.label else self.url
class Tag(StoredObject): value = fields.StringField(primary=True, index=False) count = fields.StringField(default='c', validate=True, index=True) misc = fields.StringField(default='') misc2 = fields.StringField(default='') created = fields.DateTimeField(validate=True) modified = fields.DateTimeField(validate=True, auto_now=True) keywords = fields.StringField( default=['keywd1', 'keywd2'], validate=[MinLengthValidator(5), MaxLengthValidator(10)], list=True) mybool = fields.BooleanField(default=False) myint = fields.IntegerField() myfloat = fields.FloatField(required=True, default=4.5) myurl = fields.StringField(validate=URLValidator())
class TrashedFileNode(StoredObject): """The graveyard for all deleted FileNodes""" _id = fields.StringField(primary=True) last_touched = fields.DateTimeField() history = fields.DictionaryField(list=True) versions = fields.ForeignField('FileVersion', list=True) node = fields.ForeignField('node', required=True) parent = fields.AbstractForeignField(default=None) is_file = fields.BooleanField(default=True) provider = fields.StringField(required=True) name = fields.StringField(required=True) path = fields.StringField(required=True) materialized_path = fields.StringField(required=True) checkout = fields.AbstractForeignField('User') deleted_by = fields.AbstractForeignField('User') deleted_on = fields.DateTimeField(auto_now_add=True) @property def deep_url(self): """Allows deleted files to resolve to a view that will provide a nice error message and http.GONE """ return self.node.web_url_for('addon_deleted_file', trashed_id=self._id) def restore(self): """Recreate a StoredFileNode from the data in this object Will re-point all guids and finally remove itself :raises KeyExistsException: """ data = self.to_storage() data.pop('deleted_on') data.pop('deleted_by') restored = FileNode.resolve_class(self.provider, int(self.is_file))(**data) restored.save() TrashedFileNode.remove_one(self) return restored
class RepoMeta(StoredObject): _meta = {'optimistic': True} _id = fields.StringField(primary=True) is_public = fields.BooleanField(default=False) access_read = fields.ForeignField( 'KeyAuthContext', backref='read_repos', list=True, ) access_write = fields.ForeignField( 'KeyAuthContext', backref='write_repos', list=True, ) access_admin = fields.ForeignField( 'KeyAuthContext', backref='admin_repos', list=True, )
class AddonSettingsBase(StoredObject): _id = fields.StringField(default=lambda: str(ObjectId())) deleted = fields.BooleanField(default=False) _meta = { 'abstract': True, } def delete(self, save=True): self.deleted = True self.on_delete() if save: self.save() def undelete(self, save=True): self.deleted = False self.on_add() if save: self.save() def to_json(self, user): return { 'addon_short_name': self.config.short_name, 'addon_full_name': self.config.full_name, } ############# # Callbacks # ############# def on_add(self): """Called when the addon is added (or re-added) to the owner (User or Node).""" pass def on_delete(self): """Called when the addon is deleted from the owner (User or Node).""" pass
class OsfStorageGuidFile(GuidFile): _path = fields.StringField(index=True) premigration_path = fields.StringField(index=True) path = fields.StringField(required=True, index=True) # Marker for invalid GUIDs that are associated with a node but not # part of a GUID's file tree, e.g. those generated by spiders _has_no_file_tree = fields.BooleanField(default=False) @property def waterbutler_path(self): return '/' + self.path @property def provider(self): return 'osfstorage' @property def version_identifier(self): return 'version' @property def unique_identifier(self): return self._metadata_cache['extra']['version'] @property def file_url(self): return os.path.join('osfstorage', 'files', self.path) def get_download_path(self, version_idx): url = furl.furl('/{0}/'.format(self._id)) url.args.update({ 'action': 'download', 'version': version_idx, 'mode': 'render', }) return url.url
class OsfStorageNodeSettings(AddonNodeSettingsBase): complete = True has_auth = True root_node = fields.ForeignField('OsfStorageFileNode') file_tree = fields.ForeignField('OsfStorageFileTree') # Temporary field to mark that a record has been migrated by the # migrate_from_oldels scripts _migrated_from_old_models = fields.BooleanField(default=False) def on_add(self): if self.root_node: return # A save is required here to both create and attach the root_node # When on_add is called the model that self refers to does not yet exist # in the database and thus odm cannot attach foreign fields to it self.save() # Note: The "root" node will always be "named" empty string root = OsfStorageFileNode(name='', kind='folder', node_settings=self) root.save() self.root_node = root self.save() def find_or_create_file_guid(self, path): return OsfStorageGuidFile.get_or_create(self.owner, path) def after_fork(self, node, fork, user, save=True): clone = self.clone() clone.owner = fork clone.save() clone.root_node = utils.copy_files(self.root_node, clone) clone.save() return clone, None def after_register(self, node, registration, user, save=True): clone = self.clone() clone.owner = registration clone.save() clone.root_node = utils.copy_files(self.root_node, clone) clone.save() return clone, None def serialize_waterbutler_settings(self): return dict( settings.WATERBUTLER_SETTINGS, **{ 'nid': self.owner._id, 'rootId': self.root_node._id, 'baseUrl': self.owner.api_url_for('osfstorage_get_metadata', _absolute=True, _offload=True) }) def serialize_waterbutler_credentials(self): return settings.WATERBUTLER_CREDENTIALS def create_waterbutler_log(self, auth, action, metadata): url = self.owner.web_url_for('addon_view_or_download_file', path=metadata['path'], provider='osfstorage') self.owner.add_log( 'osf_storage_{0}'.format(action), auth=auth, params={ 'node': self.owner._id, 'project': self.owner.parent_id, 'path': metadata['materialized'], 'urls': { 'view': url, 'download': url + '?action=download' }, }, )
class OsfStorageFileNode(StoredObject): """A node in the file tree of a given project Contains references to a fileversion and stores information about deletion status and position in the tree root / | \ child1 | child3 child2 / grandchild1 """ _id = fields.StringField(primary=True, default=lambda: str(bson.ObjectId())) is_deleted = fields.BooleanField(default=False) name = fields.StringField(required=True, index=True) kind = fields.StringField(required=True, index=True) parent = fields.ForeignField('OsfStorageFileNode', index=True) versions = fields.ForeignField('OsfStorageFileVersion', list=True) node_settings = fields.ForeignField('OsfStorageNodeSettings', required=True, index=True) @classmethod def create_child_by_path(cls, path, node_settings): """Attempts to create a child node from a path formatted as /parentid/child_name or /parentid/child_name/ returns created, child_node """ try: parent_id, child_name = path.strip('/').split('/') parent = cls.get_folder(parent_id, node_settings) except ValueError: try: parent, (child_name, ) = node_settings.root_node, path.strip( '/').split('/') except ValueError: raise errors.InvalidPathError( 'Path {} is invalid'.format(path)) try: if path.endswith('/'): return True, parent.append_folder(child_name) else: return True, parent.append_file(child_name) except KeyExistsException: if path.endswith('/'): return False, parent.find_child_by_name(child_name, kind='folder') else: return False, parent.find_child_by_name(child_name, kind='file') @classmethod def get(cls, path, node_settings): path = path.strip('/') if not path: return node_settings.root_node return cls.find_one( Q('_id', 'eq', path) & Q('node_settings', 'eq', node_settings)) @classmethod def get_folder(cls, path, node_settings): path = path.strip('/') if not path: return node_settings.root_node return cls.find_one( Q('_id', 'eq', path) & Q('kind', 'eq', 'folder') & Q('node_settings', 'eq', node_settings)) @classmethod def get_file(cls, path, node_settings): return cls.find_one( Q('_id', 'eq', path.strip('/')) & Q('kind', 'eq', 'file') & Q('node_settings', 'eq', node_settings)) @property @utils.must_be('folder') def children(self): return self.__class__.find(Q('parent', 'eq', self._id)) @property def is_folder(self): return self.kind == 'folder' @property def is_file(self): return self.kind == 'file' @property def path(self): return '/{}{}'.format(self._id, '/' if self.is_folder else '') @property def node(self): return self.node_settings.owner def materialized_path(self): """creates the full path to a the given filenode Note: Possibly high complexity/ many database calls USE SPARINGLY """ if not self.parent: return '/' # Note: ODM cache can be abused here # for highly nested folders calling # list(self.__class__.find(Q(nodesetting),Q(folder)) # may result in a massive increase in performance def lineage(): current = self while current: yield current current = current.parent path = os.path.join(*reversed([x.name for x in lineage()])) if self.is_folder: return '/{}/'.format(path) return '/{}'.format(path) @utils.must_be('folder') def find_child_by_name(self, name, kind='file'): return self.__class__.find_one( Q('name', 'eq', name) & Q('kind', 'eq', kind) & Q('parent', 'eq', self)) def append_folder(self, name, save=True): return self._create_child(name, 'folder', save=save) def append_file(self, name, save=True): return self._create_child(name, 'file', save=save) @utils.must_be('folder') def _create_child(self, name, kind, save=True): child = OsfStorageFileNode(name=name, kind=kind, parent=self, node_settings=self.node_settings) if save: child.save() return child def get_download_count(self, version=None): if self.is_folder: return None parts = ['download', self.node._id, self._id] if version is not None: parts.append(version) page = ':'.join([format(part) for part in parts]) _, count = get_basic_counters(page) return count or 0 @utils.must_be('file') def get_version(self, index=-1, required=False): try: return self.versions[index] except IndexError: if required: raise errors.VersionNotFoundError return None @utils.must_be('file') def create_version(self, creator, location, metadata=None): latest_version = self.get_version() version = OsfStorageFileVersion(creator=creator, location=location) if latest_version and latest_version.is_duplicate(version): return latest_version if metadata: version.update_metadata(metadata) version.save() self.versions.append(version) self.save() return version @utils.must_be('file') def update_version_metadata(self, location, metadata): for version in reversed(self.versions): if version.location == location: version.update_metadata(metadata) return raise errors.VersionNotFoundError def delete(self, recurse=True): trashed = OsfStorageTrashedFileNode() trashed._id = self._id trashed.name = self.name trashed.kind = self.kind trashed.parent = self.parent trashed.versions = self.versions trashed.node_settings = self.node_settings trashed.save() if self.is_folder and recurse: for child in self.children: child.delete() self.__class__.remove_one(self) def serialized(self, include_full=False): """Build Treebeard JSON for folder or file. """ data = { 'id': self._id, 'path': self.path, 'name': self.name, 'kind': self.kind, 'size': self.versions[0].size if self.versions else None, 'version': len(self.versions), 'downloads': self.get_download_count(), } if include_full: data['fullPath'] = self.materialized_path() return data def copy_under(self, destination_parent, name=None): return utils.copy_files(self, destination_parent.node_settings, destination_parent, name=name) def move_under(self, destination_parent, name=None): self.name = name or self.name self.parent = destination_parent self.node_settings = destination_parent.node_settings self.save() return self def __repr__(self): return '<{}(name={!r}, node_settings={!r})>'.format( self.__class__.__name__, self.name, self.to_storage()['node_settings'])
class ArchiveJob(StoredObject): _id = fields.StringField(primary=True, default=lambda: str(ObjectId())) # whether or not the ArchiveJob is complete (success or fail) done = fields.BooleanField(default=False) # whether or not emails have been sent for this ArchiveJob sent = fields.BooleanField(default=False) status = fields.StringField(default=ARCHIVER_INITIATED) datetime_initiated = fields.DateTimeField(default=datetime.datetime.utcnow) dst_node = fields.ForeignField('node', backref='active') src_node = fields.ForeignField('node') initiator = fields.ForeignField('user') target_addons = fields.ForeignField('archivetarget', list=True) # This field is used for stashing embargo URLs while still in the app context # Format: { # 'view': <str> url, # 'approve': <str> url, # 'disapprove': <str> url, # } meta = fields.DictionaryField() def __repr__(self): return ( '<{ClassName}(_id={self._id}, done={self.done}, ' ' status={self.status}, src_node={self.src_node}, dst_node={self.dst_node})>' ).format(ClassName=self.__class__.__name__, self=self) @property def children(self): return [ node.archive_job for node in self.dst_node.nodes if node.primary ] @property def parent(self): parent_node = self.dst_node.parent_node return parent_node.archive_job if parent_node else None @property def success(self): return self.status == ARCHIVER_SUCCESS @property def pending(self): return any([ target for target in self.target_addons if target.status not in (ARCHIVER_SUCCESS, ARCHIVER_FAILURE) ]) def info(self): return self.src_node, self.dst_node, self.initiator def target_info(self): return [{ 'name': target.name, 'status': target.status, 'stat_result': target.stat_result, 'errors': target.errors } for target in self.target_addons] def archive_tree_finished(self): if not self.pending: return len([ ret for ret in [child.archive_tree_finished() for child in self.children] if ret ]) if len(self.children) else True return False def _fail_above(self): """Marks all ArchiveJob instances attached to Nodes above this as failed """ parent = self.parent if parent: parent.status = ARCHIVER_FAILURE parent.save() def _post_update_target(self): """Checks for success or failure if the ArchiveJob on self.dst_node is finished """ if self.status == ARCHIVER_FAILURE: return if not self.pending: self.done = True if any([ target.status for target in self.target_addons if target.status in ARCHIVER_FAILURE_STATUSES ]): self.status = ARCHIVER_FAILURE self._fail_above() else: self.status = ARCHIVER_SUCCESS self.save() def get_target(self, addon_short_name): try: return [ addon for addon in self.target_addons if addon.name == addon_short_name ][0] except IndexError: return None def _set_target(self, addon_short_name): if self.get_target(addon_short_name): return target = ArchiveTarget(name=addon_short_name) target.save() self.target_addons.append(target) def set_targets(self): addons = [] for addon in [ self.src_node.get_addon(name) for name in settings.ADDONS_ARCHIVABLE if settings.ADDONS_ARCHIVABLE[name] != 'none' ]: if not addon or not addon.complete or not isinstance( addon, StorageAddonBase): continue archive_errors = getattr(addon, 'archive_errors', None) if not archive_errors or (archive_errors and not archive_errors()): if addon.config.short_name == 'dataverse': addons.append(addon.config.short_name + '-draft') addons.append(addon.config.short_name + '-published') else: addons.append(addon.config.short_name) for addon in addons: self._set_target(addon) self.save() def update_target(self, addon_short_name, status, stat_result=None, errors=None): stat_result = stat_result or {} errors = errors or [] target = self.get_target(addon_short_name) target.status = status target.errors = errors target.stat_result = stat_result target.save() self._post_update_target()
class PreprintService(GuidStoredObject): _id = fields.StringField(primary=True) date_created = fields.DateTimeField(auto_now_add=True) date_modified = fields.DateTimeField(auto_now=True) provider = fields.ForeignField('PreprintProvider', index=True) node = fields.ForeignField('Node', index=True) is_published = fields.BooleanField(default=False, index=True) date_published = fields.DateTimeField() # This is a list of tuples of Subject id's. MODM doesn't do schema # validation for DictionaryFields, but would unsuccessfully attempt # to validate the schema for a list of lists of ForeignFields. # # Format: [[root_subject._id, ..., child_subject._id], ...] subjects = fields.DictionaryField(list=True) @property def primary_file(self): if not self.node: return return self.node.preprint_file @property def article_doi(self): if not self.node: return return self.node.preprint_article_doi @property def is_preprint_orphan(self): if not self.node: return return self.node.is_preprint_orphan @property def deep_url(self): # Required for GUID routing return '/preprints/{}/'.format(self._primary_key) @property def url(self): return '/{}/'.format(self._id) @property def absolute_url(self): return urlparse.urljoin(settings.DOMAIN, self.url) @property def absolute_api_v2_url(self): path = '/preprints/{}/'.format(self._id) return api_v2_url(path) def get_subjects(self): ret = [] for subj_list in self.subjects: subj_hierarchy = [] for subj_id in subj_list: subj = Subject.load(subj_id) if subj: subj_hierarchy += ({'id': subj_id, 'text': subj.text}, ) if subj_hierarchy: ret.append(subj_hierarchy) return ret def set_subjects(self, preprint_subjects, auth, save=False): if not self.node.has_permission(auth.user, ADMIN): raise PermissionsError('Only admins can change a preprint\'s subjects.') self.subjects = [] for subj_list in preprint_subjects: subj_hierarchy = [] for s in subj_list: subj_hierarchy.append(s) if subj_hierarchy: validate_subject_hierarchy(subj_hierarchy) self.subjects.append(subj_hierarchy) if save: self.save() def set_primary_file(self, preprint_file, auth, save=False): if not self.node.has_permission(auth.user, ADMIN): raise PermissionsError('Only admins can change a preprint\'s primary file.') if not isinstance(preprint_file, StoredFileNode): preprint_file = preprint_file.stored_object if preprint_file.node != self.node or preprint_file.provider != 'osfstorage': raise ValueError('This file is not a valid primary file for this preprint.') # there is no preprint file yet! This is the first time! if not self.node.preprint_file: self.node.preprint_file = preprint_file elif preprint_file != self.node.preprint_file: # if there was one, check if it's a new file self.node.preprint_file = preprint_file self.node.add_log( action=NodeLog.PREPRINT_FILE_UPDATED, params={}, auth=auth, save=False, ) if save: self.save() self.node.save() def set_published(self, published, auth, save=False): if not self.node.has_permission(auth.user, ADMIN): raise PermissionsError('Only admins can publish a preprint.') if self.is_published and not published: raise ValueError('Cannot unpublish preprint.') self.is_published = published if published: if not (self.node.preprint_file and self.node.preprint_file.node == self.node): raise ValueError('Preprint node is not a valid preprint; cannot publish.') if not self.provider: raise ValueError('Preprint provider not specified; cannot publish.') if not self.subjects: raise ValueError('Preprint must have at least one subject to be published.') self.date_published = datetime.datetime.utcnow() self.node._has_abandoned_preprint = False self.node.add_log(action=NodeLog.PREPRINT_INITIATED, params={}, auth=auth, save=False) if not self.node.is_public: self.node.set_privacy( self.node.PUBLIC, auth=None, log=True ) if save: self.node.save() self.save() def save(self, *args, **kwargs): saved_fields = super(PreprintService, self).save(*args, **kwargs) if saved_fields: enqueue_task(on_preprint_updated.s(self._id))
class User(GuidStoredObject, AddonModelMixin): # Node fields that trigger an update to the search engine on save SEARCH_UPDATE_FIELDS = { 'fullname', 'given_name', 'middle_names', 'family_name', 'suffix', 'merged_by', 'date_disabled', 'date_confirmed', 'jobs', 'schools', 'social', } # TODO: Add SEARCH_UPDATE_NODE_FIELDS, for fields that should trigger a # search update for all nodes to which the user is a contributor. SOCIAL_FIELDS = { 'orcid': u'http://orcid.com/{}', 'github': u'http://github.com/{}', 'scholar': u'http://scholar.google.com/citation?user={}', 'twitter': u'http://twitter.com/{}', 'personal': u'{}', 'linkedIn': u'https://www.linkedin.com/profile/view?id={}', 'impactStory': u'https://impactstory.org/{}', 'researcherId': u'http://researcherid.com/rid/{}', } # This is a GuidStoredObject, so this will be a GUID. _id = fields.StringField(primary=True) # The primary email address for the account. # This value is unique, but multiple "None" records exist for: # * unregistered contributors where an email address was not provided. # TODO: Update mailchimp subscription on username change in user.save() username = fields.StringField(required=False, unique=True, index=True) # Hashed. Use `User.set_password` and `User.check_password` password = fields.StringField() fullname = fields.StringField(required=True, validate=string_required) # user has taken action to register the account is_registered = fields.BooleanField(index=True) # user has claimed the account # TODO: This should be retired - it always reflects is_registered. # While a few entries exist where this is not the case, they appear to be # the result of a bug, as they were all created over a small time span. is_claimed = fields.BooleanField(default=False, index=True) # a list of strings - for internal use system_tags = fields.StringField(list=True) # security emails that have been sent # TODO: This should be removed and/or merged with system_tags security_messages = fields.DictionaryField() # Format: { # <message label>: <datetime> # ... # } # user was invited (as opposed to registered unprompted) is_invited = fields.BooleanField(default=False, index=True) # Per-project unclaimed user data: # TODO: add validation unclaimed_records = fields.DictionaryField(required=False) # Format: { # <project_id>: { # 'name': <name that referrer provided>, # 'referrer_id': <user ID of referrer>, # 'token': <token used for verification urls>, # 'email': <email the referrer provided or None>, # 'claimer_email': <email the claimer entered or None>, # 'last_sent': <timestamp of last email sent to referrer or None> # } # ... # } # Time of last sent notification email to newly added contributors # Format : { # <project_id>: { # 'last_sent': time.time() # } # ... # } contributor_added_email_records = fields.DictionaryField(default=dict) # The user into which this account was merged merged_by = fields.ForeignField('user', default=None, backref='merged', index=True) # verification key used for resetting password verification_key = fields.StringField() # confirmed emails # emails should be stripped of whitespace and lower-cased before appending # TODO: Add validator to ensure an email address only exists once across # all User's email lists emails = fields.StringField(list=True) # email verification tokens # see also ``unconfirmed_emails`` email_verifications = fields.DictionaryField(default=dict) # Format: { # <token> : {'email': <email address>, # 'expiration': <datetime>} # } # email lists to which the user has chosen a subscription setting mailing_lists = fields.DictionaryField() # Format: { # 'list1': True, # 'list2: False, # ... # } # the date this user was registered # TODO: consider removal - this can be derived from date_registered date_registered = fields.DateTimeField(auto_now_add=dt.datetime.utcnow, index=True) # watched nodes are stored via a list of WatchConfigs watched = fields.ForeignField("WatchConfig", list=True, backref="watched") # list of users recently added to nodes as a contributor recently_added = fields.ForeignField("user", list=True, backref="recently_added") # Attached external accounts (OAuth) external_accounts = fields.ForeignField("externalaccount", list=True, backref="connected") # CSL names given_name = fields.StringField() middle_names = fields.StringField() family_name = fields.StringField() suffix = fields.StringField() # Employment history jobs = fields.DictionaryField(list=True, validate=validate_history_item) # Format: { # 'title': <position or job title>, # 'institution': <institution or organization>, # 'department': <department>, # 'location': <location>, # 'startMonth': <start month>, # 'startYear': <start year>, # 'endMonth': <end month>, # 'endYear': <end year>, # 'ongoing: <boolean> # } # Educational history schools = fields.DictionaryField(list=True, validate=validate_history_item) # Format: { # 'degree': <position or job title>, # 'institution': <institution or organization>, # 'department': <department>, # 'location': <location>, # 'startMonth': <start month>, # 'startYear': <start year>, # 'endMonth': <end month>, # 'endYear': <end year>, # 'ongoing: <boolean> # } # Social links social = fields.DictionaryField(validate=validate_social) # Format: { # 'personal': <personal site>, # 'twitter': <twitter id>, # } # hashed password used to authenticate to Piwik piwik_token = fields.StringField() # date the user last logged in via the web interface date_last_login = fields.DateTimeField() # date the user first successfully confirmed an email address date_confirmed = fields.DateTimeField(index=True) # When the user was disabled. date_disabled = fields.DateTimeField(index=True) # when comments for a node were last viewed comments_viewed_timestamp = fields.DictionaryField() # Format: { # 'node_id': 'timestamp' # } # timezone for user's locale (e.g. 'America/New_York') timezone = fields.StringField(default='Etc/UTC') # user language and locale data (e.g. 'en_US') locale = fields.StringField(default='en_US') _meta = {'optimistic': True} def __repr__(self): return '<User({0!r}) with id {1!r}>'.format(self.username, self._id) def __str__(self): return self.fullname.encode('ascii', 'replace') __unicode__ = __str__ # For compatibility with Django auth @property def pk(self): return self._id @property def email(self): return self.username def is_authenticated(self): # Needed for django compat return True def is_anonymous(self): return False @property def absolute_api_v2_url(self): from api.base.utils import absolute_reverse # Avoid circular dependency return absolute_reverse('users:user-detail', kwargs={'user_id': self.pk}) # used by django and DRF def get_absolute_url(self): return self.absolute_api_v2_url @classmethod def create_unregistered(cls, fullname, email=None): """Create a new unregistered user. """ user = cls( username=email, fullname=fullname, is_invited=True, is_registered=False, ) user.update_guessed_names() return user @classmethod def create(cls, username, password, fullname): user = cls( username=username, fullname=fullname, ) user.update_guessed_names() user.set_password(password) return user @classmethod def create_unconfirmed(cls, username, password, fullname, do_confirm=True): """Create a new user who has begun registration but needs to verify their primary email address (username). """ user = cls.create(username, password, fullname) user.add_unconfirmed_email(username) user.is_registered = False return user @classmethod def create_confirmed(cls, username, password, fullname): user = cls.create(username, password, fullname) user.is_registered = True user.is_claimed = True user.date_confirmed = user.date_registered return user @classmethod def from_cookie(cls, cookie, secret=None): """Attempt to load a user from their signed cookie :returns: None if a user cannot be loaded else User """ if not cookie: return None secret = secret or settings.SECRET_KEY try: token = itsdangerous.Signer(secret).unsign(cookie) except itsdangerous.BadSignature: return None user_session = Session.load(token) if user_session is None: return None return cls.load(user_session.data.get('auth_user_id')) def get_or_create_cookie(self, secret=None): """Find the cookie for the given user Create a new session if no cookie is found :param str secret: The key to sign the cookie with :returns: The signed cookie """ secret = secret or settings.SECRET_KEY sessions = Session.find(Q('data.auth_user_id', 'eq', self._id)).sort('-date_modified').limit(1) if sessions.count() > 0: user_session = sessions[0] else: user_session = Session( data={ 'auth_user_id': self._id, 'auth_user_username': self.username, 'auth_user_fullname': self.fullname, }) user_session.save() signer = itsdangerous.Signer(secret) return signer.sign(user_session._id) def update_guessed_names(self): """Updates the CSL name fields inferred from the the full name. """ parsed = utils.impute_names(self.fullname) self.given_name = parsed['given'] self.middle_names = parsed['middle'] self.family_name = parsed['family'] self.suffix = parsed['suffix'] def register(self, username, password=None): """Registers the user. """ self.username = username if password: self.set_password(password) if username not in self.emails: self.emails.append(username) self.is_registered = True self.is_claimed = True self.date_confirmed = dt.datetime.utcnow() self.update_search() self.update_search_nodes() # Emit signal that a user has confirmed signals.user_confirmed.send(self) return self def add_unclaimed_record(self, node, referrer, given_name, email=None): """Add a new project entry in the unclaimed records dictionary. :param Node node: Node this unclaimed user was added to. :param User referrer: User who referred this user. :param str given_name: The full name that the referrer gave for this user. :param str email: The given email address. :returns: The added record """ if not node.can_edit(user=referrer): raise PermissionsError( 'Referrer does not have permission to add a contributor ' 'to project {0}'.format(node._primary_key)) project_id = node._primary_key referrer_id = referrer._primary_key if email: clean_email = email.lower().strip() else: clean_email = None record = { 'name': given_name, 'referrer_id': referrer_id, 'token': generate_confirm_token(), 'email': clean_email } self.unclaimed_records[project_id] = record return record def display_full_name(self, node=None): """Return the full name , as it would display in a contributor list for a given node. NOTE: Unclaimed users may have a different name for different nodes. """ if node: unclaimed_data = self.unclaimed_records.get( node._primary_key, None) if unclaimed_data: return unclaimed_data['name'] return self.fullname @property def is_active(self): """Returns True if the user is active. The user must have activated their account, must not be deleted, suspended, etc. :return: bool """ return (self.is_registered and self.password is not None and not self.is_merged and not self.is_disabled and self.is_confirmed) def get_unclaimed_record(self, project_id): """Get an unclaimed record for a given project_id. :raises: ValueError if there is no record for the given project. """ try: return self.unclaimed_records[project_id] except KeyError: # reraise as ValueError raise ValueError( 'No unclaimed record for user {self._id} on node {project_id}'. format(**locals())) def get_claim_url(self, project_id, external=False): """Return the URL that an unclaimed user should use to claim their account. Return ``None`` if there is no unclaimed_record for the given project ID. :param project_id: The project ID for the unclaimed record :raises: ValueError if a record doesn't exist for the given project ID :rtype: dict :returns: The unclaimed record for the project """ uid = self._primary_key base_url = settings.DOMAIN if external else '/' unclaimed_record = self.get_unclaimed_record(project_id) token = unclaimed_record['token'] return '{base_url}user/{uid}/{project_id}/claim/?token={token}'\ .format(**locals()) def set_password(self, raw_password): """Set the password for this user to the hash of ``raw_password``.""" self.password = generate_password_hash(raw_password) def check_password(self, raw_password): """Return a boolean of whether ``raw_password`` was correct.""" if not self.password or not raw_password: return False return check_password_hash(self.password, raw_password) @property def csl_given_name(self): parts = [self.given_name] if self.middle_names: parts.extend(each[0] for each in re.split(r'\s+', self.middle_names)) return ' '.join(parts) @property def csl_name(self): return { 'family': self.family_name, 'given': self.csl_given_name, } # TODO: This should not be on the User object. def change_password(self, raw_old_password, raw_new_password, raw_confirm_password): """Change the password for this user to the hash of ``raw_new_password``.""" raw_old_password = (raw_old_password or '').strip() raw_new_password = (raw_new_password or '').strip() raw_confirm_password = (raw_confirm_password or '').strip() issues = [] if not self.check_password(raw_old_password): issues.append('Old password is invalid') elif raw_old_password == raw_new_password: issues.append('Password cannot be the same') if not raw_old_password or not raw_new_password or not raw_confirm_password: issues.append('Passwords cannot be blank') elif len(raw_new_password) < 6: issues.append('Password should be at least six characters') elif len(raw_new_password) > 256: issues.append('Password should not be longer than 256 characters') if raw_new_password != raw_confirm_password: issues.append('Password does not match the confirmation') if issues: raise ChangePasswordError(issues) self.set_password(raw_new_password) def _set_email_token_expiration(self, token, expiration=None): """Set the expiration date for given email token. :param str token: The email token to set the expiration for. :param datetime expiration: Datetime at which to expire the token. If ``None``, the token will expire after ``settings.EMAIL_TOKEN_EXPIRATION`` hours. This is only used for testing purposes. """ expiration = expiration or (dt.datetime.utcnow() + dt.timedelta( hours=settings.EMAIL_TOKEN_EXPIRATION)) self.email_verifications[token]['expiration'] = expiration return expiration def add_unconfirmed_email(self, email, expiration=None): """Add an email verification token for a given email.""" # TODO: This is technically not compliant with RFC 822, which requires # that case be preserved in the "local-part" of an address. From # a practical standpoint, the vast majority of email servers do # not preserve case. # ref: https://tools.ietf.org/html/rfc822#section-6 email = email.lower().strip() if email in self.emails: raise ValueError("Email already confirmed to this user.") utils.validate_email(email) # If the unconfirmed email is already present, refresh the token if email in self.unconfirmed_emails: self.remove_unconfirmed_email(email) token = generate_confirm_token() # handle when email_verifications is None if not self.email_verifications: self.email_verifications = {} self.email_verifications[token] = {'email': email} self._set_email_token_expiration(token, expiration=expiration) return token def remove_unconfirmed_email(self, email): """Remove an unconfirmed email addresses and their tokens.""" for token, value in self.email_verifications.iteritems(): if value.get('email') == email: del self.email_verifications[token] return True return False def remove_email(self, email): """Remove a confirmed email""" if email == self.username: raise PermissionsError("Can't remove primary email") if email in self.emails: self.emails.remove(email) signals.user_email_removed.send(self, email=email) @signals.user_email_removed.connect def _send_email_removal_confirmations(self, email): mails.send_mail( to_addr=self.username, mail=mails.REMOVED_EMAIL, user=self, removed_email=email, security_addr='alternate email address ({})'.format(email)) mails.send_mail(to_addr=email, mail=mails.REMOVED_EMAIL, user=self, removed_email=email, security_addr='primary email address ({})'.format( self.username)) def get_confirmation_token(self, email, force=False): """Return the confirmation token for a given email. :param str email: Email to get the token for. :param bool force: If an expired token exists for the given email, generate a new token and return that token. :raises: ExpiredTokenError if trying to access a token that is expired and force=False. :raises: KeyError if there no token for the email. """ # TODO: Refactor "force" flag into User.get_or_add_confirmation_token for token, info in self.email_verifications.items(): if info['email'].lower() == email.lower(): # Old records will not have an expiration key. If it's missing, # assume the token is expired expiration = info.get('expiration') if not expiration or (expiration and expiration < dt.datetime.utcnow()): if not force: raise ExpiredTokenError( 'Token for email "{0}" is expired'.format(email)) else: new_token = self.add_unconfirmed_email(email) self.save() return new_token return token raise KeyError('No confirmation token for email "{0}"'.format(email)) def get_confirmation_url(self, email, external=True, force=False): """Return the confirmation url for a given email. :raises: ExpiredTokenError if trying to access a token that is expired. :raises: KeyError if there is no token for the email. """ base = settings.DOMAIN if external else '/' token = self.get_confirmation_token(email, force=force) return "{0}confirm/{1}/{2}/".format(base, self._primary_key, token) def _get_unconfirmed_email_for_token(self, token): """Return whether or not a confirmation token is valid for this user. :rtype: bool """ if token not in self.email_verifications: raise exceptions.InvalidTokenError() verification = self.email_verifications[token] # Not all tokens are guaranteed to have expiration dates if ('expiration' in verification and verification['expiration'] < dt.datetime.utcnow()): raise exceptions.ExpiredTokenError() return verification['email'] def verify_claim_token(self, token, project_id): """Return whether or not a claim token is valid for this user for a given node which they were added as a unregistered contributor for. """ try: record = self.get_unclaimed_record(project_id) except ValueError: # No unclaimed record for given pid return False return record['token'] == token def confirm_email(self, token, merge=False): """Confirm the email address associated with the token""" email = self._get_unconfirmed_email_for_token(token) # If this email is confirmed on another account, abort try: user_to_merge = User.find_one(Q('emails', 'iexact', email)) except NoResultsFound: user_to_merge = None if user_to_merge and merge: self.merge_user(user_to_merge) elif user_to_merge: raise exceptions.MergeConfirmedRequiredError( 'Merge requires confirmation', user=self, user_to_merge=user_to_merge, ) # If another user has this email as its username, get it try: unregistered_user = User.find_one( Q('username', 'eq', email) & Q('_id', 'ne', self._id)) except NoResultsFound: unregistered_user = None if unregistered_user: self.merge_user(unregistered_user) self.save() unregistered_user.username = None if email not in self.emails: self.emails.append(email) # Complete registration if primary email if email.lower() == self.username.lower(): self.register(self.username) self.date_confirmed = dt.datetime.utcnow() # Revoke token del self.email_verifications[token] # TODO: We can't assume that all unclaimed records are now claimed. # Clear unclaimed records, so user's name shows up correctly on # all projects self.unclaimed_records = {} self.save() self.update_search_nodes() return True @property def unconfirmed_emails(self): # Handle when email_verifications field is None email_verifications = self.email_verifications or {} return [each['email'] for each in email_verifications.values()] def update_search_nodes(self): """Call `update_search` on all nodes on which the user is a contributor. Needed to add self to contributor lists in search upon registration or claiming. """ for node in self.node__contributed: node.update_search() def update_search_nodes_contributors(self): """ Bulk update contributor name on all nodes on which the user is a contributor. :return: """ from website.search import search search.update_contributors(self.visible_contributor_to) @property def is_confirmed(self): return bool(self.date_confirmed) @property def social_links(self): return { key: self.SOCIAL_FIELDS[key].format(val) for key, val in self.social.items() if val and self.SOCIAL_FIELDS.get(key) } @property def biblio_name(self): given_names = self.given_name + ' ' + self.middle_names surname = self.family_name if surname != given_names: initials = [ name[0].upper() + '.' for name in given_names.split(' ') if name and re.search(r'\w', name[0], re.I) ] return u'{0}, {1}'.format(surname, ' '.join(initials)) return surname @property def given_name_initial(self): """ The user's preferred initialization of their given name. Some users with common names may choose to distinguish themselves from their colleagues in this way. For instance, there could be two well-known researchers in a single field named "Robert Walker". "Walker, R" could then refer to either of them. "Walker, R.H." could provide easy disambiguation. NOTE: The internal representation for this should never end with a period. "R" and "R.H" would be correct in the prior case, but "R.H." would not. """ return self.given_name[0] @property def url(self): return '/{}/'.format(self._primary_key) @property def api_url(self): return '/api/v1/profile/{0}/'.format(self._primary_key) @property def absolute_url(self): return urlparse.urljoin(settings.DOMAIN, self.url) @property def display_absolute_url(self): url = self.absolute_url if url is not None: return re.sub(r'https?:', '', url).strip('/') @property def deep_url(self): return '/profile/{}/'.format(self._primary_key) @property def gravatar_url(self): return filters.gravatar(self, use_ssl=True, size=settings.GRAVATAR_SIZE_ADD_CONTRIBUTOR) def get_activity_points(self, db=None): db = db or framework.mongo.database return analytics.get_total_activity_count(self._primary_key, db=db) @property def is_disabled(self): """Whether or not this account has been disabled. Abstracts ``User.date_disabled``. :return: bool """ return self.date_disabled is not None @is_disabled.setter def is_disabled(self, val): """Set whether or not this account has been disabled.""" if val: self.date_disabled = dt.datetime.utcnow() else: self.date_disabled = None @property def is_merged(self): '''Whether or not this account has been merged into another account. ''' return self.merged_by is not None @property def profile_url(self): return '/{}/'.format(self._id) @property def contributor_to(self): return (node for node in self.node__contributed if not (node.is_deleted or node.is_dashboard)) @property def visible_contributor_to(self): return (node for node in self.contributor_to if self._id in node.visible_contributor_ids) def get_summary(self, formatter='long'): return { 'user_fullname': self.fullname, 'user_profile_url': self.profile_url, 'user_display_name': name_formatters[formatter](self), 'user_is_claimed': self.is_claimed } def save(self, *args, **kwargs): # TODO: Update mailchimp subscription on username change # Avoid circular import from framework.analytics import tasks as piwik_tasks self.username = self.username.lower().strip( ) if self.username else None ret = super(User, self).save(*args, **kwargs) if self.SEARCH_UPDATE_FIELDS.intersection(ret) and self.is_confirmed: self.update_search() self.update_search_nodes_contributors() if settings.PIWIK_HOST and not self.piwik_token: piwik_tasks.update_user(self._id) return ret def update_search(self): from website import search try: search.search.update_user(self) except search.exceptions.SearchUnavailableError as e: logger.exception(e) log_exception() @classmethod def find_by_email(cls, email): try: user = cls.find_one(Q('emails', 'eq', email)) return [user] except: return [] def serialize(self, anonymous=False): return { 'id': utils.privacy_info_handle(self._primary_key, anonymous), 'fullname': utils.privacy_info_handle(self.fullname, anonymous, name=True), 'registered': self.is_registered, 'url': utils.privacy_info_handle(self.url, anonymous), 'api_url': utils.privacy_info_handle(self.api_url, anonymous), } ###### OSF-Specific methods ###### def watch(self, watch_config): """Watch a node by adding its WatchConfig to this user's ``watched`` list. Raises ``ValueError`` if the node is already watched. :param watch_config: The WatchConfig to add. :param save: Whether to save the user. """ watched_nodes = [each.node for each in self.watched] if watch_config.node in watched_nodes: raise ValueError('Node is already being watched.') watch_config.save() self.watched.append(watch_config) return None def unwatch(self, watch_config): """Unwatch a node by removing its WatchConfig from this user's ``watched`` list. Raises ``ValueError`` if the node is not already being watched. :param watch_config: The WatchConfig to remove. :param save: Whether to save the user. """ for each in self.watched: if watch_config.node._id == each.node._id: each.__class__.remove_one(each) return None raise ValueError('Node not being watched.') def is_watching(self, node): '''Return whether a not a user is watching a Node.''' watched_node_ids = set([config.node._id for config in self.watched]) return node._id in watched_node_ids def get_recent_log_ids(self, since=None): '''Return a generator of recent logs' ids. :param since: A datetime specifying the oldest time to retrieve logs from. If ``None``, defaults to 60 days before today. Must be a tz-aware datetime because PyMongo's generation times are tz-aware. :rtype: generator of log ids (strings) ''' log_ids = [] # Default since to 60 days before today if since is None # timezone aware utcnow utcnow = dt.datetime.utcnow().replace(tzinfo=pytz.utc) since_date = since or (utcnow - dt.timedelta(days=60)) for config in self.watched: # Extract the timestamps for each log from the log_id (fast!) # The first 4 bytes of Mongo's ObjectId encodes time # This prevents having to load each Log Object and access their # date fields node_log_ids = [ log_id for log_id in config.node.logs._to_primary_keys() if bson.ObjectId(log_id).generation_time > since_date and log_id not in log_ids ] # Log ids in reverse chronological order log_ids = _merge_into_reversed(log_ids, node_log_ids) return (l_id for l_id in log_ids) def get_daily_digest_log_ids(self): '''Return a generator of log ids generated in the past day (starting at UTC 00:00). ''' utcnow = dt.datetime.utcnow() midnight = dt.datetime(utcnow.year, utcnow.month, utcnow.day, 0, 0, 0, tzinfo=pytz.utc) return self.get_recent_log_ids(since=midnight) @property def can_be_merged(self): """The ability of the `merge_user` method to fully merge the user""" return all((addon.can_be_merged for addon in self.get_addons())) def merge_user(self, user): """Merge a registered user into this account. This user will be a contributor on any project. if the registered user and this account are both contributors of the same project. Then it will remove the registered user and set this account to the highest permission of the two and set this account to be visible if either of the two are visible on the project. :param user: A User object to be merged. """ # Fail if the other user has conflicts. if not user.can_be_merged: raise exceptions.MergeConflictError("Users cannot be merged") # Move over the other user's attributes # TODO: confirm for system_tag in user.system_tags: if system_tag not in self.system_tags: self.system_tags.append(system_tag) self.is_claimed = self.is_claimed or user.is_claimed self.is_invited = self.is_invited or user.is_invited # copy over profile only if this user has no profile info if user.jobs and not self.jobs: self.jobs = user.jobs if user.schools and not self.schools: self.schools = user.schools if user.social and not self.social: self.social = user.social unclaimed = user.unclaimed_records.copy() unclaimed.update(self.unclaimed_records) self.unclaimed_records = unclaimed # - unclaimed records should be connected to only one user user.unclaimed_records = {} security_messages = user.security_messages.copy() security_messages.update(self.security_messages) self.security_messages = security_messages for key, value in user.mailing_lists.iteritems(): # subscribe to each list if either user was subscribed subscription = value or self.mailing_lists.get(key) signals.user_merged.send(self, list_name=key, subscription=subscription) # clear subscriptions for merged user signals.user_merged.send(user, list_name=key, subscription=False) for node_id, timestamp in user.comments_viewed_timestamp.iteritems(): if not self.comments_viewed_timestamp.get(node_id): self.comments_viewed_timestamp[node_id] = timestamp elif timestamp > self.comments_viewed_timestamp[node_id]: self.comments_viewed_timestamp[node_id] = timestamp self.emails.extend(user.emails) user.emails = [] for k, v in user.email_verifications.iteritems(): email_to_confirm = v['email'] if k not in self.email_verifications and email_to_confirm != user.username: self.email_verifications[k] = v user.email_verifications = {} # FOREIGN FIELDS for watched in user.watched: if watched not in self.watched: self.watched.append(watched) user.watched = [] for account in user.external_accounts: if account not in self.external_accounts: self.external_accounts.append(account) user.external_accounts = [] # - addons # Note: This must occur before the merged user is removed as a # contributor on the nodes, as an event hook is otherwise fired # which removes the credentials. for addon in user.get_addons(): user_settings = self.get_or_add_addon(addon.config.short_name) user_settings.merge(addon) user_settings.save() # - projects where the user was a contributor for node in user.node__contributed: # Skip dashboard node if node.is_dashboard: continue # if both accounts are contributor of the same project if node.is_contributor(self) and node.is_contributor(user): if node.permissions[user._id] > node.permissions[self._id]: permissions = node.permissions[user._id] else: permissions = node.permissions[self._id] node.set_permissions(user=self, permissions=permissions) visible1 = self._id in node.visible_contributor_ids visible2 = user._id in node.visible_contributor_ids if visible1 != visible2: node.set_visible(user=self, visible=True, log=True, auth=Auth(user=self)) else: node.add_contributor( contributor=self, permissions=node.get_permissions(user), visible=node.get_visible(user), log=False, ) try: node.remove_contributor( contributor=user, auth=Auth(user=self), log=False, ) except ValueError: logger.error('Contributor {0} not in list on node {1}'.format( user._id, node._id)) node.save() # - projects where the user was the creator for node in user.node__created: node.creator = self node.save() # finalize the merge remove_sessions_for_user(user) # - username is set to None so the resultant user can set it primary # in the future. user.username = None user.password = None user.verification_key = None user.merged_by = self user.save() def get_projects_in_common(self, other_user, primary_keys=True): """Returns either a collection of "shared projects" (projects that both users are contributors for) or just their primary keys """ if primary_keys: projects_contributed_to = set( self.node__contributed._to_primary_keys()) return projects_contributed_to.intersection( other_user.node__contributed._to_primary_keys()) else: projects_contributed_to = set(self.node__contributed) return projects_contributed_to.intersection( other_user.node__contributed) def n_projects_in_common(self, other_user): """Returns number of "shared projects" (projects that both users are contributors for)""" return len(self.get_projects_in_common(other_user, primary_keys=True))
class OsfStorageNodeSettings(AddonNodeSettingsBase): file_tree = fields.ForeignField('OsfStorageFileTree') root_node = fields.ForeignField('OsfStorageFileNode') # Temporary field to mark that a record has been migrated by the # migrate_from_oldels scripts _migrated_from_old_models = fields.BooleanField(default=False) def on_add(self): if self.root_node: return self.save() root = OsfStorageFileNode(name='', kind='folder', node_settings=self) root.save() self.root_node = root self.save() @property def has_auth(self): return True @property def complete(self): return True def find_or_create_file_guid(self, path): return OsfStorageGuidFile.get_or_create(node=self.owner, path=path.lstrip('/')) def copy_contents_to(self, dest): """Copy file tree and contents to destination. Note: destination must be saved before copying so that copied items can refer to it. :param OsfStorageNodeSettings dest: Destination settings object """ dest.save() if self.file_tree: dest.file_tree = copy_file_tree(self.file_tree, dest) dest.save() def after_fork(self, node, fork, user, save=True): clone, message = super(OsfStorageNodeSettings, self).after_fork(node=node, fork=fork, user=user, save=False) self.copy_contents_to(clone) return clone, message def after_register(self, node, registration, user, save=True): clone = self.clone() clone.owner = registration self.copy_contents_to(clone) if save: clone.save() return clone, None def serialize_waterbutler_settings(self): ret = { 'callback': self.owner.api_url_for('osf_storage_update_metadata_hook', _absolute=True, _offload=True), 'metadata': self.owner.api_url_for('osf_storage_get_metadata_hook', _absolute=True, _offload=True), 'revisions': self.owner.api_url_for('osf_storage_get_revisions', _absolute=True, _offload=True), } ret.update(settings.WATERBUTLER_SETTINGS) return ret def serialize_waterbutler_credentials(self): return settings.WATERBUTLER_CREDENTIALS def create_waterbutler_log(self, auth, action, metadata): pass
class Document(StoredObject): _id = fields.StringField(default=make_oid) document_type = fields.StringField() filepath = fields.StringField() extract_filepath = fields.StringField() url = fields.StringField() verification_score = fields.FloatField() extract_path = fields.StringField() extracted = fields.BooleanField() _meta = { 'abstract': True, } def read(self): raise NotImplementedError def text_file_name(self): _, tail = os.path.split(self.filepath) root, _= os.path.splitext(tail) return '{}.txt'.format(root) def save_extract(self, text, save=True, **kwargs): path = EXTRACT_SAVE_DIRS[self.document_type] if not os.path.exists(path): mkdir_p(path) filepath = os.path.join(path, self.text_file_name()) self.extract_filepath = filepath self.extracted = True for key, value in kwargs.iteritems(): setattr(self, key, value) open(filepath, 'w').write( to_unicode(text).encode('utf-8') ) if save: self.save() def verify(self, threshold, overwrite=False): """Verify that the document matches the target article: checks that the document contains a minimum fraction of words in the article abstract. :param float threshold: Minimum fraction of abstract words present :param bool overwrite: Recalculate existing verification score :return bool: Article is verified """ # Return stored if self.verification_score and not overwrite: return self.verification_score > threshold text = self.read() # Load target article try: article = self.article__scraped[0] except IndexError: return False # AB -> Abstract abstract = article. record.get('AB', None) if not text or not abstract: return False text = text.lower() abstract = abstract.lower() abstract_tokens = re.split(r'\s+', abstract) tokens_contained = [ token for token in abstract_tokens if token in text ] prop_contained = len(tokens_contained) / len(abstract_tokens) self.verification_score = prop_contained self.save() return prop_contained >= threshold
class OsfStorageFileRecord(BaseFileObject): _id = oid_primary_key is_deleted = fields.BooleanField(default=False) versions = fields.ForeignField('OsfStorageFileVersion', list=True) @classmethod def parent_class(cls): return OsfStorageFileTree def get_version(self, index=-1, required=False): try: return self.versions[index] except IndexError: if required: raise errors.VersionNotFoundError return None def get_versions(self, page, size=settings.REVISIONS_PAGE_SIZE): start = len(self.versions) - (page * size) stop = max(0, start - size) indices = range(start, stop, -1) versions = [self.versions[idx - 1] for idx in indices] more = stop > 0 return indices, versions, more def create_version(self, creator, location, metadata=None): latest_version = self.get_version() version = OsfStorageFileVersion(creator=creator, location=location) if latest_version and latest_version.is_duplicate(version): if self.is_deleted: self.undelete(Auth(creator)) return latest_version if metadata: version.update_metadata(metadata) version.save() self.versions.append(version) self.is_deleted = False self.save() self.log( Auth(creator), NodeLog.FILE_UPDATED if len(self.versions) > 1 else NodeLog.FILE_ADDED, ) return version def update_version_metadata(self, location, metadata): for version in reversed(self.versions): if version.location == location: version.update_metadata(metadata) return raise errors.VersionNotFoundError def log(self, auth, action, version=True): node_logger = logs.OsfStorageNodeLogger( auth=auth, node=self.node, path=self.path, ) extra = {'version': len(self.versions)} if version else None node_logger.log(action, extra=extra, save=True) def delete(self, auth, log=True): if self.is_deleted: raise errors.DeleteError self.is_deleted = True self.save() if log: self.log(auth, NodeLog.FILE_REMOVED, version=False) def undelete(self, auth, log=True): if not self.is_deleted: raise errors.UndeleteError self.is_deleted = False self.save() if log: self.log(auth, NodeLog.FILE_ADDED) def get_download_count(self, version=None): """ :param int version: Optional one-based version index """ parts = ['download', self.node._id, self.path] if version is not None: parts.append(version) page = ':'.join([format(part) for part in parts]) _, count = get_basic_counters(page) return count or 0
class NodeWikiPage(GuidStoredObject): _id = fields.StringField(primary=True) page_name = fields.StringField(validate=validate_page_name) version = fields.IntegerField() date = fields.DateTimeField(auto_now_add=datetime.datetime.utcnow) is_current = fields.BooleanField() content = fields.StringField(default='') user = fields.ForeignField('user') node = fields.ForeignField('node') @property def deep_url(self): return '{}wiki/{}/'.format(self.node.deep_url, self.page_name) @property def url(self): return '{}wiki/{}/'.format(self.node.url, self.page_name) @property def rendered_before_update(self): return self.date < WIKI_CHANGE_DATE def html(self, node): """The cleaned HTML of the page""" sanitized_content = render_content(self.content, node=node) try: return linkify( sanitized_content, [ nofollow, ], ) except TypeError: logger.warning('Returning unlinkified content.') return sanitized_content def raw_text(self, node): """ The raw text of the page, suitable for using in a test search""" return sanitize(self.html(node), tags=[], strip=True) def get_draft(self, node): """ Return most recently edited version of wiki, whether that is the last saved version or the most recent sharejs draft. """ db = wiki_utils.share_db() sharejs_uuid = wiki_utils.get_sharejs_uuid(node, self.page_name) doc_item = db['docs'].find_one({'_id': sharejs_uuid}) if doc_item: sharejs_version = doc_item['_v'] sharejs_timestamp = doc_item['_m']['mtime'] sharejs_timestamp /= 1000 # Convert to appropriate units sharejs_date = datetime.datetime.utcfromtimestamp( sharejs_timestamp) if sharejs_version > 1 and sharejs_date > self.date: return doc_item['_data'] return self.content def save(self, *args, **kwargs): rv = super(NodeWikiPage, self).save(*args, **kwargs) if self.node: self.node.update_search() return rv def rename(self, new_name, save=True): self.page_name = new_name if save: self.save() def to_json(self): return {}
class Embargo(PreregCallbackMixin, EmailApprovableSanction): """Embargo object for registrations waiting to go public.""" DISPLAY_NAME = 'Embargo' SHORT_NAME = 'embargo' AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_EMBARGO_ADMIN NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_EMBARGO_NON_ADMIN VIEW_URL_TEMPLATE = VIEW_PROJECT_URL_TEMPLATE APPROVE_URL_TEMPLATE = settings.DOMAIN + 'project/{node_id}/?token={token}' REJECT_URL_TEMPLATE = settings.DOMAIN + 'project/{node_id}/?token={token}' initiated_by = fields.ForeignField('user', backref='embargoed') for_existing_registration = fields.BooleanField(default=False) @property def is_completed(self): return self.state == self.COMPLETED @property def embargo_end_date(self): if self.state == self.APPROVED: return self.end_date return False # NOTE(hrybacki): Old, private registrations are grandfathered and do not # require to be made public or embargoed. This field differentiates them # from new registrations entering into an embargo field which should not # show up in any search related fields. @property def pending_registration(self): return not self.for_existing_registration and self.is_pending_approval def __repr__(self): from website.project.model import Node parent_registration = None try: parent_registration = Node.find_one(Q('embargo', 'eq', self)) except NoResultsFound: pass return ('<Embargo(parent_registration={0}, initiated_by={1}, ' 'end_date={2}) with _id {3}>').format(parent_registration, self.initiated_by, self.end_date, self._id) def _get_registration(self): from website.project.model import Node return Node.find_one(Q('embargo', 'eq', self)) def _view_url_context(self, user_id, node): registration = node or self._get_registration() return {'node_id': registration._id} def _approval_url_context(self, user_id): user_approval_state = self.approval_state.get(user_id, {}) approval_token = user_approval_state.get('approval_token') if approval_token: registration = self._get_registration() node_id = user_approval_state.get('node_id', registration._id) return { 'node_id': node_id, 'token': approval_token, } def _rejection_url_context(self, user_id): user_approval_state = self.approval_state.get(user_id, {}) rejection_token = user_approval_state.get('rejection_token') if rejection_token: from website.project.model import Node root_registration = self._get_registration() node_id = user_approval_state.get('node_id', root_registration._id) registration = Node.load(node_id) return { 'node_id': registration.registered_from, 'token': rejection_token, } def _email_template_context(self, user, node, is_authorizer=False, urls=None): context = super(Embargo, self)._email_template_context( user, node, is_authorizer=is_authorizer) urls = urls or self.stashed_urls.get(user._id, {}) registration_link = urls.get('view', self._view_url(user._id, node)) if is_authorizer: approval_link = urls.get('approve', '') disapproval_link = urls.get('reject', '') approval_time_span = settings.EMBARGO_PENDING_TIME.days * 24 registration = self._get_registration() context.update({ 'is_initiator': self.initiated_by == user, 'initiated_by': self.initiated_by.fullname, 'approval_link': approval_link, 'project_name': registration.title, 'disapproval_link': disapproval_link, 'registration_link': registration_link, 'embargo_end_date': self.end_date, 'approval_time_span': approval_time_span, }) else: context.update({ 'initiated_by': self.initiated_by.fullname, 'registration_link': registration_link, 'embargo_end_date': self.end_date, }) return context def _on_reject(self, user): from website.project.model import NodeLog parent_registration = self._get_registration() parent_registration.registered_from.add_log( action=NodeLog.EMBARGO_CANCELLED, params={ 'node': parent_registration._id, 'embargo_id': self._id, }, auth=Auth(user), ) # Remove backref to parent project if embargo was for a new registration if not self.for_existing_registration: parent_registration.delete_registration_tree(save=True) parent_registration.registered_from = None # Delete parent registration if it was created at the time the embargo was initiated if not self.for_existing_registration: parent_registration.is_deleted = True parent_registration.save() def disapprove_embargo(self, user, token): """Cancels retraction if user is admin and token verifies.""" self.reject(user, token) def _on_complete(self, user): from website.project.model import NodeLog super(Embargo, self)._on_complete(user) parent_registration = self._get_registration() parent_registration.registered_from.add_log( action=NodeLog.EMBARGO_APPROVED, params={ 'node': parent_registration._id, 'embargo_id': self._id, }, auth=Auth(self.initiated_by), ) self.save() def approve_embargo(self, user, token): """Add user to approval list if user is admin and token verifies.""" self.approve(user, token) def mark_as_completed(self): self.state = Sanction.COMPLETED self.save()
class AddonWikiNodeSettings(AddonNodeSettingsBase): complete = True has_auth = True is_publicly_editable = fields.BooleanField(default=False, index=True) def set_editing(self, permissions, auth=None, log=False): """Set the editing permissions for this node. :param auth: All the auth information including user, API key :param bool permissions: True = publicly editable :param bool save: Whether to save the privacy change :param bool log: Whether to add a NodeLog for the privacy change if true the node object is also saved """ node = self.owner if permissions and not self.is_publicly_editable: if node.is_public: self.is_publicly_editable = True else: raise NodeStateError('Private components cannot be made publicly editable.') elif not permissions and self.is_publicly_editable: self.is_publicly_editable = False else: raise NodeStateError('Desired permission change is the same as current setting.') if log: node.add_log( action=(NodeLog.MADE_WIKI_PUBLIC if self.is_publicly_editable else NodeLog.MADE_WIKI_PRIVATE), params={ 'project': node.parent_id, 'node': node._primary_key, }, auth=auth, save=False, ) node.save() self.save() def after_fork(self, node, fork, user, save=True): """Copy wiki settings and wiki pages to forks.""" NodeWikiPage.clone_wiki_versions(node, fork, user, save) return super(AddonWikiNodeSettings, self).after_fork(node, fork, user, save) def after_register(self, node, registration, user, save=True): """Copy wiki settings and wiki pages to registrations.""" NodeWikiPage.clone_wiki_versions(node, registration, user, save) clone = self.clone() clone.owner = registration if save: clone.save() return clone, None def after_set_privacy(self, node, permissions): """ :param Node node: :param str permissions: :return str: Alert message """ if permissions == 'private': if self.is_publicly_editable: self.set_editing(permissions=False, log=False) return ( 'The wiki of {name} is now only editable by write contributors.'.format( name=node.title, ) ) def to_json(self, user): return {}
class User(GuidStoredObject, AddonModelMixin): redirect_mode = 'proxy' # Node fields that trigger an update to the search engine on save SEARCH_UPDATE_FIELDS = { 'fullname', 'given_name', 'middle_names', 'family_name', 'suffix', 'merged_by', 'date_disabled', 'jobs', 'schools', 'social', } SOCIAL_FIELDS = { 'orcid': 'http://orcid.com/{}', 'github': 'http://github.com/{}', 'scholar': 'http://scholar.google.com/citation?user={}', 'twitter': 'http://twitter.com/{}', 'personal': '{}', 'linkedIn': 'https://www.linkedin.com/profile/view?id={}', 'impactStory': 'https://impactstory.org/{}', 'researcherId': 'http://researcherid.com/rid/{}', } _id = fields.StringField(primary=True) # NOTE: In the OSF, username is an email # May be None for unregistered contributors username = fields.StringField(required=False, unique=True, index=True) password = fields.StringField() fullname = fields.StringField(required=True, validate=string_required) is_registered = fields.BooleanField() # TODO: Migrate unclaimed users to the new style, then remove this attribute # Note: No new users should be created where is_claimed is False. # As of 9 Sep 2014, there were 331 legacy unclaimed users in the system. # When those users are migrated to the new style, this attribute should be # removed. is_claimed = fields.BooleanField() # Tags for internal use system_tags = fields.StringField(list=True) # Per-project unclaimed user data: # Format: { # <project_id>: { # 'name': <name that referrer provided>, # 'referrer_id': <user ID of referrer>, # 'token': <token used for verification urls>, # 'email': <email the referrer provided or None>, # 'last_sent': <timestamp of last email sent to referrer or None> # } # ... # } # TODO: add validation unclaimed_records = fields.DictionaryField(required=False) # The user who merged this account merged_by = fields.ForeignField('user', default=None, backref="merged") #: Verification key used for resetting password verification_key = fields.StringField() emails = fields.StringField(list=True) # Email verification tokens # Format: { # <token> : {'email': <email address>, # 'expiration': <datetime>} # } email_verifications = fields.DictionaryField() # Format: { # 'list1': True, # 'list2: False, # ... # } mailing_lists = fields.DictionaryField() aka = fields.StringField(list=True) date_registered = fields.DateTimeField(auto_now_add=dt.datetime.utcnow) # Watched nodes are stored via a list of WatchConfigs watched = fields.ForeignField("WatchConfig", list=True, backref="watched") # Recently added contributors stored via a list of users recently_added = fields.ForeignField("user", list=True, backref="recently_added") # CSL names given_name = fields.StringField() middle_names = fields.StringField() family_name = fields.StringField() suffix = fields.StringField() # Employment history # Format: { # 'title': <position or job title>, # 'institution': <institution or organization>, # 'department': <department>, # 'location': <location>, # 'startMonth': <start month>, # 'startYear': <start year>, # 'endMonth': <end month>, # 'endYear': <end year>, # 'ongoing: <boolean> # } jobs = fields.DictionaryField(list=True, validate=validate_history_item) # Educational history # Format: { # 'degree': <position or job title>, # 'institution': <institution or organization>, # 'department': <department>, # 'location': <location>, # 'startMonth': <start month>, # 'startYear': <start year>, # 'endMonth': <end month>, # 'endYear': <end year>, # 'ongoing: <boolean> # } schools = fields.DictionaryField(list=True, validate=validate_history_item) # Social links # Format: { # 'personal': <personal site>, # 'twitter': <twitter id>, # } social = fields.DictionaryField(validate=validate_social) api_keys = fields.ForeignField('apikey', list=True, backref='keyed') piwik_token = fields.StringField() date_last_login = fields.DateTimeField() date_confirmed = fields.DateTimeField() # When the user was disabled. date_disabled = fields.DateTimeField() # Format: { # 'node_id': 'timestamp' # } comments_viewed_timestamp = fields.DictionaryField() _meta = {'optimistic': True} def __repr__(self): return '<User({0!r}) with id {1!r}>'.format(self.username, self._id) @classmethod def create_unregistered(cls, fullname, email=None): """Creates a new unregistered user. :raises: DuplicateEmailError if a user with the given email address is already in the database. """ user = cls( username=email, fullname=fullname, ) user.update_guessed_names() if email: user.emails.append(email) user.is_registered = False return user @classmethod def create(cls, username, password, fullname): user = cls( username=username, fullname=fullname, ) user.update_guessed_names() user.set_password(password) return user @classmethod def create_unconfirmed(cls, username, password, fullname, do_confirm=True): """Create a new user who has begun registration but needs to verify their primary email address (username). """ user = cls.create(username, password, fullname) user.add_email_verification(username) user.is_registered = False return user @classmethod def create_confirmed(cls, username, password, fullname): user = cls.create(username, password, fullname) user.is_registered = True user.is_claimed = True user.date_confirmed = user.date_registered return user def update_guessed_names(self): """Updates the CSL name fields inferred from the the full name. """ parsed = utils.impute_names(self.fullname) self.given_name = parsed['given'] self.middle_names = parsed['middle'] self.family_name = parsed['family'] self.suffix = parsed['suffix'] def register(self, username, password=None): """Registers the user. """ self.username = username if password: self.set_password(password) if username not in self.emails: self.emails.append(username) self.is_registered = True self.is_claimed = True self.date_confirmed = dt.datetime.utcnow() self.update_search() self.update_search_nodes() # Emit signal that a user has confirmed signals.user_confirmed.send(self) return self def add_unclaimed_record(self, node, referrer, given_name, email=None): """Add a new project entry in the unclaimed records dictionary. :param Node node: Node this unclaimed user was added to. :param User referrer: User who referred this user. :param str given_name: The full name that the referrer gave for this user. :param str email: The given email address. :returns: The added record """ if not node.can_edit(user=referrer): raise PermissionsError( 'Referrer does not have permission to add a contributor ' 'to project {0}'.format(node._primary_key)) project_id = node._primary_key referrer_id = referrer._primary_key if email: clean_email = email.lower().strip() else: clean_email = None record = { 'name': given_name, 'referrer_id': referrer_id, 'token': generate_confirm_token(), 'email': clean_email } self.unclaimed_records[project_id] = record return record def display_full_name(self, node=None): """Return the full name , as it would display in a contributor list for a given node. NOTE: Unclaimed users may have a different name for different nodes. """ if node: unclaimed_data = self.unclaimed_records.get( node._primary_key, None) if unclaimed_data: return unclaimed_data['name'] return self.fullname @property def is_active(self): """Returns True if the user is active. The user must have activated their account, must not be deleted, suspended, etc. :return: bool """ return (self.is_registered and self.password is not None and not self.is_merged and not self.is_disabled and self.is_confirmed()) def get_unclaimed_record(self, project_id): """Get an unclaimed record for a given project_id. :raises: ValueError if there is no record for the given project. """ try: return self.unclaimed_records[project_id] except KeyError: # reraise as ValueError raise ValueError( 'No unclaimed record for user {self._id} on node {project_id}'. format(**locals())) def get_claim_url(self, project_id, external=False): """Return the URL that an unclaimed user should use to claim their account. Return ``None`` if there is no unclaimed_record for the given project ID. :param project_id: The project ID for the unclaimed record :raises: ValueError if a record doesn't exist for the given project ID :rtype: dict :returns: The unclaimed record for the project """ uid = self._primary_key base_url = settings.DOMAIN if external else '/' unclaimed_record = self.get_unclaimed_record(project_id) token = unclaimed_record['token'] return '{base_url}user/{uid}/{project_id}/claim/?token={token}'\ .format(**locals()) def set_password(self, raw_password): """Set the password for this user to the hash of ``raw_password``.""" self.password = generate_password_hash(raw_password) def check_password(self, raw_password): """Return a boolean of whether ``raw_password`` was correct.""" if not self.password or not raw_password: return False return check_password_hash(self.password, raw_password) def change_password(self, raw_old_password, raw_new_password, raw_confirm_password): """Change the password for this user to the hash of ``raw_new_password``.""" raw_old_password = (raw_old_password or '').strip() raw_new_password = (raw_new_password or '').strip() raw_confirm_password = (raw_confirm_password or '').strip() issues = [] if not self.check_password(raw_old_password): issues.append('Old password is invalid') elif raw_old_password == raw_new_password: issues.append('Password cannot be the same') if not raw_old_password or not raw_new_password or not raw_confirm_password: issues.append('Passwords cannot be blank') elif len(raw_new_password) < 6: issues.append('Password should be at least six characters') if raw_new_password != raw_confirm_password: issues.append('Password does not match the confirmation') if issues: raise ChangePasswordError(issues) self.set_password(raw_new_password) def _set_email_token_expiration(self, token, expiration=None): """Set the expiration date for given email token. :param str token: The email token to set the expiration for. :param datetime expiration: Datetime at which to expire the token. If ``None``, the token will expire after ``settings.EMAIL_TOKEN_EXPIRATION`` hours. This is only used for testing purposes. """ expiration = expiration or (dt.datetime.utcnow() + dt.timedelta( hours=settings.EMAIL_TOKEN_EXPIRATION)) self.email_verifications[token]['expiration'] = expiration return expiration def add_email_verification(self, email, expiration=None): """Add an email verification token for a given email.""" token = generate_confirm_token() self.email_verifications[token] = {'email': email.lower()} self._set_email_token_expiration(token, expiration=expiration) return token def get_confirmation_token(self, email, force=False): """Return the confirmation token for a given email. :param str email: Email to get the token for. :param bool force: If an expired token exists for the given email, generate a new token and return that token. :raises: ExpiredTokenError if trying to access a token that is expired and force=False. :raises: KeyError if there no token for the email. """ for token, info in self.email_verifications.items(): if info['email'].lower() == email.lower(): if info['expiration'] < dt.datetime.utcnow(): if not force: raise ExpiredTokenError( 'Token for email "{0}" is expired'.format(email)) else: new_token = self.add_email_verification(email) self.save() return new_token return token raise KeyError('No confirmation token for email "{0}"'.format(email)) def get_confirmation_url(self, email, external=True, force=False): """Return the confirmation url for a given email. :raises: ExpiredTokenError if trying to access a token that is expired. :raises: KeyError if there is no token for the email. """ base = settings.DOMAIN if external else '/' token = self.get_confirmation_token(email, force=force) return "{0}confirm/{1}/{2}/".format(base, self._primary_key, token) def verify_confirmation_token(self, token): """Return whether or not a confirmation token is valid for this user. :rtype: bool """ if token in self.email_verifications.keys(): return self.email_verifications.get( token)['expiration'] > dt.datetime.utcnow() return False def verify_claim_token(self, token, project_id): """Return whether or not a claim token is valid for this user for a given node which they were added as a unregistered contributor for. """ try: record = self.get_unclaimed_record(project_id) except ValueError: # No unclaimed record for given pid return False return record['token'] == token def confirm_email(self, token): if self.verify_confirmation_token(token): email = self.email_verifications[token]['email'] self.emails.append(email) # Complete registration if primary email if email.lower() == self.username.lower(): self.register(self.username) self.date_confirmed = dt.datetime.utcnow() # Revoke token del self.email_verifications[token] # Clear unclaimed records, so user's name shows up correctly on # all projects self.unclaimed_records = {} self.save() # Note: We must manually update search here because the fullname # field has not changed self.update_search() self.update_search_nodes() return True else: return False def update_search_nodes(self): """Call `update_search` on all nodes on which the user is a contributor. Needed to add self to contributor lists in search upon registration or claiming. """ for node in self.node__contributed: node.update_search() def is_confirmed(self): return bool(self.date_confirmed) @property def social_links(self): return { key: self.SOCIAL_FIELDS[key].format(val) for key, val in self.social.items() if val and self.SOCIAL_FIELDS.get(key) } @property def biblio_name(self): given_names = self.given_name + ' ' + self.middle_names surname = self.family_name if surname != given_names: initials = [ name[0].upper() + '.' for name in given_names.split(' ') if name and re.search(r'\w', name[0], re.I) ] return u'{0}, {1}'.format(surname, ' '.join(initials)) return surname @property def given_name_initial(self): """ The user's preferred initialization of their given name. Some users with common names may choose to distinguish themselves from their colleagues in this way. For instance, there could be two well-known researchers in a single field named "Robert Walker". "Walker, R" could then refer to either of them. "Walker, R.H." could provide easy disambiguation. NOTE: The internal representation for this should never end with a period. "R" and "R.H" would be correct in the prior case, but "R.H." would not. """ return self.given_name[0] @property def url(self): return '/{}/'.format(self._primary_key) @property def api_url(self): return '/api/v1/profile/{0}/'.format(self._primary_key) @property def absolute_url(self): return urlparse.urljoin(settings.DOMAIN, self.url) @property def display_absolute_url(self): url = self.absolute_url if url is not None: return re.sub(r'https?:', '', url).strip('/') @property def deep_url(self): return '/profile/{}/'.format(self._primary_key) @property def gravatar_url(self): return filters.gravatar(self, use_ssl=True, size=settings.GRAVATAR_SIZE_ADD_CONTRIBUTOR) def get_activity_points(self, db=None): db = db or framework.mongo.database return analytics.get_total_activity_count(self._primary_key, db=db) @property def is_disabled(self): """Whether or not this account has been disabled. Abstracts ``User.date_disabled``. :return: bool """ return self.date_disabled is not None @is_disabled.setter def is_disabled(self, val): """Set whether or not this account has been disabled.""" if val: self.date_disabled = dt.datetime.utcnow() else: self.date_disabled = None @property def is_merged(self): '''Whether or not this account has been merged into another account. ''' return self.merged_by is not None @property def profile_url(self): return '/{}/'.format(self._id) def get_summary(self, formatter='long'): return { 'user_fullname': self.fullname, 'user_profile_url': self.profile_url, 'user_display_name': name_formatters[formatter](self), 'user_is_claimed': self.is_claimed } def save(self, *args, **kwargs): self.username = self.username.lower().strip( ) if self.username else None ret = super(User, self).save(*args, **kwargs) if self.SEARCH_UPDATE_FIELDS.intersection(ret) and self.is_confirmed(): self.update_search() if settings.PIWIK_HOST and not self.piwik_token: try: piwik.create_user(self) except (piwik.PiwikException, ValueError): logger.error("Piwik user creation failed: " + self._id) return ret def update_search(self): from website import search try: search.search.update_user(self) except search.exceptions.SearchUnavailableError as e: logger.exception(e) log_exception() @classmethod def find_by_email(cls, email): try: user = cls.find_one(Q('emails', 'eq', email)) return [user] except: return [] def serialize(self, anonymous=False): return { 'id': utils.privacy_info_handle(self._primary_key, anonymous), 'fullname': utils.privacy_info_handle(self.fullname, anonymous, name=True), 'registered': self.is_registered, 'url': utils.privacy_info_handle(self.url, anonymous), 'api_url': utils.privacy_info_handle(self.api_url, anonymous), } ###### OSF-Specific methods ###### def watch(self, watch_config): """Watch a node by adding its WatchConfig to this user's ``watched`` list. Raises ``ValueError`` if the node is already watched. :param watch_config: The WatchConfig to add. :param save: Whether to save the user. """ watched_nodes = [each.node for each in self.watched] if watch_config.node in watched_nodes: raise ValueError('Node is already being watched.') watch_config.save() self.watched.append(watch_config) return None def unwatch(self, watch_config): """Unwatch a node by removing its WatchConfig from this user's ``watched`` list. Raises ``ValueError`` if the node is not already being watched. :param watch_config: The WatchConfig to remove. :param save: Whether to save the user. """ for each in self.watched: if watch_config.node._id == each.node._id: each.__class__.remove_one(each) return None raise ValueError('Node not being watched.') def is_watching(self, node): '''Return whether a not a user is watching a Node.''' watched_node_ids = set([config.node._id for config in self.watched]) return node._id in watched_node_ids def get_recent_log_ids(self, since=None): '''Return a generator of recent logs' ids. :param since: A datetime specifying the oldest time to retrieve logs from. If ``None``, defaults to 60 days before today. Must be a tz-aware datetime because PyMongo's generation times are tz-aware. :rtype: generator of log ids (strings) ''' log_ids = [] # Default since to 60 days before today if since is None # timezone aware utcnow utcnow = dt.datetime.utcnow().replace(tzinfo=pytz.utc) since_date = since or (utcnow - dt.timedelta(days=60)) for config in self.watched: # Extract the timestamps for each log from the log_id (fast!) # The first 4 bytes of Mongo's ObjectId encodes time # This prevents having to load each Log Object and access their # date fields node_log_ids = [ log_id for log_id in config.node.logs._to_primary_keys() if bson.ObjectId(log_id).generation_time > since_date and log_id not in log_ids ] # Log ids in reverse chronological order log_ids = _merge_into_reversed(log_ids, node_log_ids) return (l_id for l_id in log_ids) def get_daily_digest_log_ids(self): '''Return a generator of log ids generated in the past day (starting at UTC 00:00). ''' utcnow = dt.datetime.utcnow() midnight = dt.datetime(utcnow.year, utcnow.month, utcnow.day, 0, 0, 0, tzinfo=pytz.utc) return self.get_recent_log_ids(since=midnight) def merge_user(self, user, save=False): """Merge a registered user into this account. This user will be a contributor on any project :param user: A User object to be merged. """ # Inherit emails self.emails.extend(user.emails) # Inherit projects the user was a contributor for for node in user.node__contributed: node.add_contributor( contributor=self, permissions=node.get_permissions(user), visible=node.get_visible(user), log=False, ) try: node.remove_contributor( contributor=user, auth=Auth(user=self), log=False, ) except ValueError: logger.error('Contributor {0} not in list on node {1}'.format( user._id, node._id)) node.save() # Inherits projects the user created for node in user.node__created: node.creator = self node.save() user.merged_by = self user.save() if save: self.save() return None def get_projects_in_common(self, other_user, primary_keys=True): """Returns either a collection of "shared projects" (projects that both users are contributors for) or just their primary keys """ if primary_keys: projects_contributed_to = set( self.node__contributed._to_primary_keys()) return projects_contributed_to.intersection( other_user.node__contributed._to_primary_keys()) else: projects_contributed_to = set(self.node__contributed) return projects_contributed_to.intersection( other_user.node__contributed) def n_projects_in_common(self, other_user): """Returns number of "shared projects" (projects that both users are contributors for)""" return len(self.get_projects_in_common(other_user, primary_keys=True))
class StoredFileNode(StoredObject): """The storage backend for FileNode objects. This class should generally not be used or created manually as FileNode contains all the helpers required. A FileNode wraps a StoredFileNode to provider usable abstraction layer """ __indices__ = [{ 'unique': False, 'key_or_list': [('path', pymongo.ASCENDING), ('node', pymongo.ASCENDING), ('is_file', pymongo.ASCENDING), ('provider', pymongo.ASCENDING)] }, { 'unique': False, 'key_or_list': [('node', pymongo.ASCENDING), ('is_file', pymongo.ASCENDING), ('provider', pymongo.ASCENDING)] }] _id = fields.StringField(primary=True, default=lambda: str(bson.ObjectId())) # The last time the touch method was called on this FileNode last_touched = fields.DateTimeField() # A list of dictionaries sorted by the 'modified' key # The raw output of the metadata request deduped by etag # Add regardless it can be pinned to a version or not history = fields.DictionaryField(list=True) # A concrete version of a FileNode, must have an identifier versions = fields.ForeignField('FileVersion', list=True) node = fields.ForeignField('Node', required=True) parent = fields.ForeignField('StoredFileNode', default=None) is_file = fields.BooleanField(default=True) provider = fields.StringField(required=True) name = fields.StringField(required=True) path = fields.StringField(required=True) materialized_path = fields.StringField(required=True) # The User that has this file "checked out" # Should only be used for OsfStorage checkout = fields.AbstractForeignField('User') #Tags for a file, currently only used for osfStorage tags = fields.ForeignField('Tag', list=True) # For Django compatibility @property def pk(self): return self._id # For Django compatibility # TODO Find a better way @property def node_id(self): return self.node._id @property def deep_url(self): return self.wrapped().deep_url def wrapped(self): """Wrap self in a FileNode subclass """ return FileNode.resolve_class(self.provider, int(self.is_file))(self) def get_guid(self, create=False): """Attempt to find a Guid that points to this object. One will be created if requested. :rtype: Guid """ try: # Note sometimes multiple GUIDs can exist for # a single object. Just go with the first one return Guid.find(Q('referent', 'eq', self))[0] except IndexError: if not create: return None return Guid.generate(self)
class EmailApprovableSanction(TokenApprovableSanction): # Tell modularodm not to attach backends _meta = { 'abstract': True, } AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = None NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = None VIEW_URL_TEMPLATE = '' APPROVE_URL_TEMPLATE = '' REJECT_URL_TEMPLATE = '' # A flag to conditionally run a callback on complete notify_initiator_on_complete = fields.BooleanField(default=False) # Store a persistant copy of urls for use when needed outside of a request context. # This field gets automagically updated whenever models approval_state is modified # and the model is saved # { # 'abcde': { # 'approve': [APPROVAL_URL], # 'reject': [REJECT_URL], # } # } stashed_urls = fields.DictionaryField(default=dict) @staticmethod def _format_or_empty(template, context): if context: return template.format(**context) return '' def _view_url(self, user_id, node): return self._format_or_empty(self.VIEW_URL_TEMPLATE, self._view_url_context(user_id, node)) def _view_url_context(self, user_id, node): return None def _approval_url(self, user_id): return self._format_or_empty(self.APPROVE_URL_TEMPLATE, self._approval_url_context(user_id)) def _approval_url_context(self, user_id): return None def _rejection_url(self, user_id): return self._format_or_empty(self.REJECT_URL_TEMPLATE, self._rejection_url_context(user_id)) def _rejection_url_context(self, user_id): return None def _send_approval_request_email(self, user, template, context): mails.send_mail(user.username, template, user=user, **context) def _email_template_context(self, user, node, is_authorizer=False): return {} def _notify_authorizer(self, authorizer, node): context = self._email_template_context(authorizer, node, is_authorizer=True) if self.AUTHORIZER_NOTIFY_EMAIL_TEMPLATE: self._send_approval_request_email( authorizer, self.AUTHORIZER_NOTIFY_EMAIL_TEMPLATE, context) else: raise NotImplementedError def _notify_non_authorizer(self, user, node): context = self._email_template_context(user, node) if self.NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE: self._send_approval_request_email( user, self.NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE, context) else: raise NotImplementedError def add_authorizer(self, user, node, **kwargs): super(EmailApprovableSanction, self).add_authorizer(user, node, **kwargs) self.stashed_urls[user._id] = { 'view': self._view_url(user._id, node), 'approve': self._approval_url(user._id), 'reject': self._rejection_url(user._id) } self.save() def _notify_initiator(self): raise NotImplementedError def _on_complete(self, *args): if self.notify_initiator_on_complete: self._notify_initiator()