1 new commit in galaxy-central: https://bitbucket.org/galaxy/galaxy-central/commits/9414cc43797a/ Changeset: 9414cc43797a User: greg Date: 2014-01-24 18:31:29 Summary: Add the ability for a tool shed repository owner to grant administrative privileges on a repository to other users or groups. This feature incorporates the tool shed's role based authorization framework. Each repository and repository owner are associated with a repository administrative role which can then be associated with others. Affected #: 13 files diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 lib/galaxy/web/base/controllers/admin.py --- a/lib/galaxy/web/base/controllers/admin.py +++ b/lib/galaxy/web/base/controllers/admin.py @@ -94,6 +94,9 @@ return self.purge_role( trans, **kwargs ) if operation == "manage users and groups": return self.manage_users_and_groups_for_role( trans, **kwargs ) + if operation == "manage role associations": + # This is currently used only in the Tool Shed. + return self.manage_role_associations( trans, **kwargs ) if operation == "rename": return self.rename_role( trans, **kwargs ) # Render the list view diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 lib/galaxy/webapps/tool_shed/controllers/admin.py --- a/lib/galaxy/webapps/tool_shed/controllers/admin.py +++ b/lib/galaxy/webapps/tool_shed/controllers/admin.py @@ -1,17 +1,14 @@ +import logging from galaxy.web.base.controller import BaseUIController -from galaxy import web, util from galaxy.web.base.controllers.admin import Admin +from galaxy import util +from galaxy import web from galaxy.util import inflector import tool_shed.util.shed_util_common as suc import tool_shed.util.metadata_util as metadata_util +from tool_shed.util import repository_maintenance_util import tool_shed.grids.admin_grids as admin_grids -from galaxy import eggs -eggs.require( 'mercurial' ) -from mercurial import hg - -import logging - log = logging.getLogger( __name__ ) @@ -27,7 +24,7 @@ @web.expose @web.require_admin def browse_repositories( self, trans, **kwd ): - # We add params to the keyword dict in this method in order to rename the param + # We add parameters to the keyword dict in this method in order to rename the param # with an "f-" prefix, simulating filtering by clicking a search link. We have # to take this approach because the "-" character is illegal in HTTP requests. if 'operation' in kwd: @@ -166,6 +163,11 @@ for repository_metadata in repository.downloadable_revisions: repository_metadata.downloadable = False trans.sa_session.add( repository_metadata ) + # Mark the repository admin role as deleted. + repository_admin_role = repository.admin_role + if repository_admin_role is not None: + repository_admin_role.deleted = True + trans.sa_session.add( repository_admin_role ) repository.deleted = True trans.sa_session.add( repository ) trans.sa_session.flush() @@ -295,6 +297,33 @@ @web.expose @web.require_admin + def manage_role_associations( self, trans, **kwd ): + """Manage users, groups and repositories associated with a role.""" + role_id = kwd.get( 'id', None ) + role = repository_maintenance_util.get_role_by_id( trans, role_id ) + # We currently only have a single role associated with a repository, the repository admin role. + repository_role_association = role.repositories[ 0 ] + repository = repository_role_association.repository + associations_dict = repository_maintenance_util.handle_role_associations( trans, role, repository, **kwd ) + in_users = associations_dict.get( 'in_users', [] ) + out_users = associations_dict.get( 'out_users', [] ) + in_groups = associations_dict.get( 'in_groups', [] ) + out_groups = associations_dict.get( 'out_groups', [] ) + message = associations_dict.get( 'message', '' ) + status = associations_dict.get( 'status', 'done' ) + return trans.fill_template( '/webapps/tool_shed/role/role.mako', + in_admin_controller=True, + repository=repository, + role=role, + in_users=in_users, + out_users=out_users, + in_groups=in_groups, + out_groups=out_groups, + message=message, + status=status ) + + @web.expose + @web.require_admin def reset_metadata_on_selected_repositories_in_tool_shed( self, trans, **kwd ): if 'reset_metadata_on_selected_repositories_button' in kwd: message, status = metadata_util.reset_metadata_on_selected_repositories( trans, **kwd ) @@ -322,13 +351,19 @@ repository = suc.get_repository_in_tool_shed( trans, repository_id ) if repository: if repository.deleted: - # Inspect all repository_metadata records to determine those that are installable, and mark them accordingly. + # Inspect all repository_metadata records to determine those that are installable, and mark + # them accordingly. for repository_metadata in repository.metadata_revisions: metadata = repository_metadata.metadata if metadata: if metadata_util.is_downloadable( metadata ): repository_metadata.downloadable = True trans.sa_session.add( repository_metadata ) + # Mark the repository admin role as not deleted. + repository_admin_role = repository.admin_role + if repository_admin_role is not None: + repository_admin_role.deleted = False + trans.sa_session.add( repository_admin_role ) repository.deleted = False trans.sa_session.add( repository ) trans.sa_session.flush() diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 lib/galaxy/webapps/tool_shed/controllers/repository.py --- a/lib/galaxy/webapps/tool_shed/controllers/repository.py +++ b/lib/galaxy/webapps/tool_shed/controllers/repository.py @@ -68,6 +68,7 @@ my_writable_repositories_with_test_install_errors_grid = repository_grids.MyWritableRepositoriesWithTestInstallErrorsGrid() repositories_by_user_grid = repository_grids.RepositoriesByUserGrid() repositories_i_own_grid = repository_grids.RepositoriesIOwnGrid() + repositories_i_can_administer_grid = repository_grids.RepositoriesICanAdministerGrid() repositories_in_category_grid = repository_grids.RepositoriesInCategoryGrid() repositories_missing_tool_test_components_grid = repository_grids.RepositoriesMissingToolTestComponentsGrid() repositories_with_failing_tool_tests_grid = repository_grids.RepositoriesWithFailingToolTestsGrid() @@ -351,8 +352,9 @@ @web.expose def browse_repositories( self, trans, **kwd ): - # We add params to the keyword dict in this method in order to rename the param with an "f-" prefix, simulating filtering by clicking a search - # link. We have to take this approach because the "-" character is illegal in HTTP requests. + # We add params to the keyword dict in this method in order to rename the param with an "f-" prefix, + # simulating filtering by clicking a search link. We have to take this approach because the "-" + # character is illegal in HTTP requests. if 'operation' in kwd: operation = kwd[ 'operation' ].lower() if operation == "view_or_manage_repository": @@ -434,6 +436,32 @@ return self.repositories_by_user_grid( trans, **kwd ) @web.expose + def browse_repositories_i_can_administer( self, trans, **kwd ): + if 'operation' in kwd: + operation = kwd[ 'operation' ].lower() + if operation == "view_or_manage_repository": + return trans.response.send_redirect( web.url_for( controller='repository', + action='view_or_manage_repository', + **kwd ) ) + elif operation == "repositories_by_user": + return trans.response.send_redirect( web.url_for( controller='repository', + action='browse_repositories_by_user', + **kwd ) ) + elif operation in [ 'mark as deprecated', 'mark as not deprecated' ]: + kwd[ 'mark_deprecated' ] = operation == 'mark as deprecated' + return trans.response.send_redirect( web.url_for( controller='repository', + action='deprecate', + **kwd ) ) + selected_changeset_revision, repository = suc.get_repository_from_refresh_on_change( trans, **kwd ) + if repository: + return trans.response.send_redirect( web.url_for( controller='repository', + action='browse_repositories', + operation='view_or_manage_repository', + id=trans.security.encode_id( repository.id ), + changeset_revision=selected_changeset_revision ) ) + return self.repositories_i_can_administer_grid( trans, **kwd ) + + @web.expose def browse_repositories_i_own( self, trans, **kwd ): if 'operation' in kwd: operation = kwd[ 'operation' ].lower() @@ -921,8 +949,8 @@ status = kwd.get( 'status', 'done' ) categories = suc.get_categories( trans ) if not categories: - message = 'No categories have been configured in this instance of the Galaxy Tool Shed. ' + \ - 'An administrator needs to create some via the Administrator control panel before creating repositories.', + message = 'No categories have been configured in this instance of the Galaxy Tool Shed. ' + message += 'An administrator needs to create some via the Administrator control panel before creating repositories.', status = 'error' return trans.response.send_redirect( web.url_for( controller='repository', action='browse_repositories', @@ -1741,8 +1769,9 @@ owner = kwd.get( 'owner', None ) changeset_revision = kwd.get( 'changeset_revision', None ) repository = suc.get_repository_by_name_and_owner( trans.app, name, owner ) - # TODO: We're currently returning the tool_dependencies.xml file that is available on disk. We need to enhance this process - # to retrieve older versions of the tool-dependencies.xml file from the repository manafest. + # TODO: We're currently returning the tool_dependencies.xml file that is available on disk. We need + # to enhance this process to retrieve older versions of the tool-dependencies.xml file from the repository + #manafest. repo_dir = repository.repo_path( trans.app ) # Get the tool_dependencies.xml file from disk. tool_dependencies_config = suc.get_config_from_disk( suc.TOOL_DEPENDENCY_DEFINITION_FILENAME, repo_dir ) @@ -1951,6 +1980,9 @@ # See if there are any RepositoryMetadata records since menu items require them. repository_metadata = trans.sa_session.query( trans.model.RepositoryMetadata ).first() current_user = trans.user + # TODO: move the following to some in-memory register so these queries can be done once + # at startup. The in-memory registe can then be managed during the current session. + can_administer_repositories = False has_reviewed_repositories = False has_deprecated_repositories = False if current_user: @@ -1964,6 +1996,16 @@ if repository.deprecated: has_deprecated_repositories = True break + # See if the current user can administer any repositories, but only if not an admin user. + if not trans.user_is_admin(): + if current_user.active_repositories: + can_administer_repositories = True + else: + for repository in trans.sa_session.query( trans.model.Repository ) \ + .filter( trans.model.Repository.table.c.deleted == False ): + if trans.app.security_agent.user_can_administer_repository( current_user, repository ): + can_administer_repositories = True + break # Route in may have been from a sharable URL, in whcih case we'll have a user_id and possibly a name # The received user_id will be the id of the repository owner. user_id = kwd.get( 'user_id', None ) @@ -1971,6 +2013,7 @@ changeset_revision = kwd.get( 'changeset_revision', None ) return trans.fill_template( '/webapps/tool_shed/index.mako', repository_metadata=repository_metadata, + can_administer_repositories=can_administer_repositories, has_reviewed_repositories=has_reviewed_repositories, has_deprecated_repositories=has_deprecated_repositories, user_id=user_id, @@ -2116,9 +2159,8 @@ user = trans.user if kwd.get( 'edit_repository_button', False ): flush_needed = False - # TODO: add a can_manage in the security agent. - if not ( user.email == repository.user.email or trans.user_is_admin() ): - message = "You are not the owner of this repository, so you cannot manage it." + if not ( trans.user_is_admin() or trans.app.security_agent.user_can_administer_repository( user, repository ) ): + message = "You are not the owner of this repository, so you cannot administer it." return trans.response.send_redirect( web.url_for( controller='repository', action='view_repository', id=id, @@ -2145,6 +2187,12 @@ # Change the entry in the repository's hgrc file. hgrc_file = os.path.join( repo_dir, '.hg', 'hgrc' ) repository_maintenance_util.change_repository_name_in_hgrc_file( hgrc_file, repo_name ) + # Rename the repository's admin role to match the new repository name. + repository_admin_role = repository.admin_role + repository_admin_role.name = \ + repository_maintenance_util.get_repository_admin_role_name( str( repo_name ), + str( repository.user.username ) ) + trans.sa_session.add( repository_admin_role ) repository.name = repo_name flush_needed = True elif repository.times_downloaded != 0 and repo_name != repository.name: @@ -2345,6 +2393,55 @@ status=status ) @web.expose + @web.require_login( "manage repository administrators" ) + def manage_repository_admins( self, trans, id, **kwd ): + message = kwd.get( 'message', '' ) + status = kwd.get( 'status', 'done' ) + repository = suc.get_repository_in_tool_shed( trans, id ) + changeset_revision = kwd.get( 'changeset_revision', repository.tip( trans.app ) ) + metadata = None + if changeset_revision != suc.INITIAL_CHANGELOG_HASH: + repository_metadata = suc.get_repository_metadata_by_changeset_revision( trans, id, changeset_revision ) + if repository_metadata: + metadata = repository_metadata.metadata + else: + # There is no repository_metadata defined for the changeset_revision, so see if it was defined + # in a previous changeset in the changelog. + repo_dir = repository.repo_path( trans.app ) + repo = hg.repository( suc.get_configured_ui(), repo_dir ) + previous_changeset_revision = \ + suc.get_previous_metadata_changeset_revision( repository, + repo, + changeset_revision, + downloadable=False ) + if previous_changeset_revision != suc.INITIAL_CHANGELOG_HASH: + repository_metadata = suc.get_repository_metadata_by_changeset_revision( trans, + id, + previous_changeset_revision ) + if repository_metadata: + metadata = repository_metadata.metadata + role = repository.admin_role + associations_dict = repository_maintenance_util.handle_role_associations( trans, role, repository, **kwd ) + in_users = associations_dict.get( 'in_users', [] ) + out_users = associations_dict.get( 'out_users', [] ) + in_groups = associations_dict.get( 'in_groups', [] ) + out_groups = associations_dict.get( 'out_groups', [] ) + message = associations_dict.get( 'message', '' ) + status = associations_dict.get( 'status', 'done' ) + return trans.fill_template( '/webapps/tool_shed/role/role.mako', + in_admin_controller=False, + repository=repository, + metadata=metadata, + changeset_revision=changeset_revision, + role=role, + in_users=in_users, + out_users=out_users, + in_groups=in_groups, + out_groups=out_groups, + message=message, + status=status ) + + @web.expose @web.require_login( "review repository revision" ) def manage_repository_reviews_of_revision( self, trans, **kwd ): return trans.response.send_redirect( web.url_for( controller='repository_review', @@ -3025,8 +3122,10 @@ repository_id = kwd.get( 'id', None ) if repository_id: repository = suc.get_repository_in_tool_shed( trans, repository_id ) + user = trans.user if repository: - if trans.user_is_admin() or repository.user == trans.user: + if user is not None and ( trans.user_is_admin() or \ + trans.app.security_agent.user_can_administer_repository( user, repository ) ): return trans.response.send_redirect( web.url_for( controller='repository', action='manage_repository', **kwd ) ) diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 lib/galaxy/webapps/tool_shed/model/__init__.py --- a/lib/galaxy/webapps/tool_shed/model/__init__.py +++ b/lib/galaxy/webapps/tool_shed/model/__init__.py @@ -40,10 +40,6 @@ roles.append( role ) return roles - def set_password_cleartext( self, cleartext ): - """Set 'self.password' to the digest of 'cleartext'.""" - self.password = new_secure_hash( text_type=cleartext ) - def check_password( self, cleartext ): """Check if 'cleartext' matches 'self.password' when hashed.""" return self.password == new_secure_hash( text_type=cleartext ) @@ -51,14 +47,18 @@ def get_disk_usage( self, nice_size=False ): return 0 + @property + def nice_total_disk_usage( self ): + return 0 + def set_disk_usage( self, bytes ): pass total_disk_usage = property( get_disk_usage, set_disk_usage ) - @property - def nice_total_disk_usage( self ): - return 0 + def set_password_cleartext( self, cleartext ): + """Set 'self.password' to the digest of 'cleartext'.""" + self.password = new_secure_hash( text_type=cleartext ) class Group( object, Dictifiable ): @@ -74,19 +74,26 @@ dict_collection_visible_keys = ( 'id', 'name' ) dict_element_visible_keys = ( 'id', 'name', 'description', 'type' ) private_id = None - types = Bunch( - PRIVATE = 'private', - SYSTEM = 'system', - USER = 'user', - ADMIN = 'admin', - SHARING = 'sharing' - ) + types = Bunch( PRIVATE = 'private', + SYSTEM = 'system', + USER = 'user', + ADMIN = 'admin', + SHARING = 'sharing' ) def __init__( self, name="", description="", type="system", deleted=False ): self.name = name self.description = description self.type = type self.deleted = deleted + + @property + def is_repository_admin_role( self ): + # A repository admin role must always be associated with a repository. The mapper returns an + # empty list for those roles that have no repositories. This method will require changes if + # new features are introduced that results in more than one role per repository. + if self.repositories: + return True + return False class UserGroupAssociation( object ): @@ -107,6 +114,12 @@ self.role = role +class RepositoryRoleAssociation( object ): + def __init__( self, repository, role ): + self.repository = repository + self.role = role + + class GalaxySession( object ): def __init__( self, @@ -131,17 +144,18 @@ class Repository( object, Dictifiable ): - dict_collection_visible_keys = ( 'id', 'name', 'type', 'description', 'user_id', 'private', 'deleted', 'times_downloaded', 'deprecated' ) - dict_element_visible_keys = ( 'id', 'name', 'type', 'description', 'long_description', 'user_id', 'private', 'deleted', 'times_downloaded', - 'deprecated' ) + dict_collection_visible_keys = ( 'id', 'name', 'type', 'description', 'user_id', 'private', 'deleted', + 'times_downloaded', 'deprecated' ) + dict_element_visible_keys = ( 'id', 'name', 'type', 'description', 'long_description', 'user_id', 'private', + 'deleted', 'times_downloaded', 'deprecated' ) file_states = Bunch( NORMAL = 'n', NEEDS_MERGING = 'm', MARKED_FOR_REMOVAL = 'r', MARKED_FOR_ADDITION = 'a', NOT_TRACKED = '?' ) - def __init__( self, id=None, name=None, type=None, description=None, long_description=None, user_id=None, private=False, deleted=None, - email_alerts=None, times_downloaded=0, deprecated=False ): + def __init__( self, id=None, name=None, type=None, description=None, long_description=None, user_id=None, private=False, + deleted=None, email_alerts=None, times_downloaded=0, deprecated=False ): self.id = id self.name = name or "Unnamed repository" self.type = type @@ -154,13 +168,23 @@ self.times_downloaded = times_downloaded self.deprecated = deprecated + @property + def admin_role( self ): + admin_role_name = '%s_%s_admin' % ( str( self.name ), str( self.user.username ) ) + for rra in self.roles: + role = rra.role + if str( role.name ) == admin_role_name: + return role + raise Exception( 'Repository %s owned by %s is not associated with a required administrative role.' % \ + ( str( self.name ), str( self.user.username ) ) ) + def allow_push( self, app ): repo = hg.repository( ui.ui(), self.repo_path( app ) ) return repo.ui.config( 'web', 'allow_push' ) def can_change_type( self, app ): - # Allow changing the type only if the repository has no contents, has never been installed, or has never been changed from - # the default type. + # Allow changing the type only if the repository has no contents, has never been installed, or has + # never been changed from the default type. if self.is_new( app ): return True if self.times_downloaded == 0: diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 lib/galaxy/webapps/tool_shed/model/mapping.py --- a/lib/galaxy/webapps/tool_shed/model/mapping.py +++ b/lib/galaxy/webapps/tool_shed/model/mapping.py @@ -74,6 +74,13 @@ Column( "create_time", DateTime, default=now ), Column( "update_time", DateTime, default=now, onupdate=now ) ) +RepositoryRoleAssociation.table = Table( "repository_role_association", metadata, + Column( "id", Integer, primary_key=True ), + Column( "repository_id", Integer, ForeignKey( "repository.id" ), index=True ), + Column( "role_id", Integer, ForeignKey( "role.id" ), index=True ), + Column( "create_time", DateTime, default=now ), + Column( "update_time", DateTime, default=now, onupdate=now ) ) + GalaxySession.table = Table( "galaxy_session", metadata, Column( "id", Integer, primary_key=True ), Column( "create_time", DateTime, default=now ), @@ -203,8 +210,17 @@ mapper( Role, Role.table, properties=dict( - users=relation( UserRoleAssociation ), - groups=relation( GroupRoleAssociation ) ) ) + repositories=relation( RepositoryRoleAssociation, + primaryjoin=( ( Role.table.c.id == RepositoryRoleAssociation.table.c.role_id ) & ( RepositoryRoleAssociation.table.c.repository_id == Repository.table.c.id ) ) ), + users=relation( UserRoleAssociation, + primaryjoin=( ( Role.table.c.id == UserRoleAssociation.table.c.role_id ) & ( UserRoleAssociation.table.c.user_id == User.table.c.id ) ) ), + groups=relation( GroupRoleAssociation, + primaryjoin=( ( Role.table.c.id == GroupRoleAssociation.table.c.role_id ) & ( GroupRoleAssociation.table.c.group_id == Group.table.c.id ) ) ) ) ) + +mapper( RepositoryRoleAssociation, RepositoryRoleAssociation.table, + properties=dict( + repository=relation( Repository ), + role=relation( Role ) ) ) mapper( UserGroupAssociation, UserGroupAssociation.table, properties=dict( user=relation( User, backref = "groups" ), @@ -245,6 +261,7 @@ order_by=desc( RepositoryMetadata.table.c.update_time ) ), metadata_revisions=relation( RepositoryMetadata, order_by=desc( RepositoryMetadata.table.c.update_time ) ), + roles=relation( RepositoryRoleAssociation ), reviews=relation( RepositoryReview, primaryjoin=( ( Repository.table.c.id == RepositoryReview.table.c.repository_id ) ) ), reviewers=relation( User, diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 lib/galaxy/webapps/tool_shed/model/migrate/versions/0022_add_repository_admin_roles.py --- /dev/null +++ b/lib/galaxy/webapps/tool_shed/model/migrate/versions/0022_add_repository_admin_roles.py @@ -0,0 +1,159 @@ +""" +Migration script to create the repository_role_association table, insert name-spaced +repository administrative roles into the role table and associate each repository and +owner with the appropriate name-spaced role. +""" +import datetime, logging, sys +from sqlalchemy import * +from sqlalchemy.orm import * +from migrate import * +from migrate.changeset import * +# Need our custom types, but don't import anything else from model +from galaxy.model.custom_types import * + +log = logging.getLogger( __name__ ) +log.setLevel(logging.DEBUG) +handler = logging.StreamHandler( sys.stdout ) +format = "%(name)s %(levelname)s %(asctime)s %(message)s" +formatter = logging.Formatter( format ) +handler.setFormatter( formatter ) +log.addHandler( handler ) + +metadata = MetaData() + +NOW = datetime.datetime.utcnow +ROLE_TYPE = 'system' + +RepositoryRoleAssociation_table = Table( "repository_role_association", metadata, + Column( "id", Integer, primary_key=True ), + Column( "repository_id", Integer, ForeignKey( "repository.id" ), index=True ), + Column( "role_id", Integer, ForeignKey( "role.id" ), index=True ), + Column( "create_time", DateTime, default=NOW ), + Column( "update_time", DateTime, default=NOW, onupdate=NOW ) ) + +def nextval( migrate_engine, table, col='id' ): + if migrate_engine.name in [ 'postgresql', 'postgres' ]: + return "nextval('%s_%s_seq')" % ( table, col ) + elif migrate_engine.name in [ 'mysql', 'sqlite' ]: + return "null" + else: + raise Exception( 'Unable to convert data for unknown database type: %s' % migrate_engine.name ) + +def localtimestamp( migrate_engine ): + if migrate_engine.name in [ 'postgresql', 'postgres', 'mysql' ]: + return "LOCALTIMESTAMP" + elif migrate_engine.name == 'sqlite': + return "current_date || ' ' || current_time" + else: + raise Exception( 'Unable to convert data for unknown database type: %s' % db ) + +def boolean_false( migrate_engine ): + if migrate_engine.name in [ 'postgresql', 'postgres', 'mysql' ]: + return False + elif migrate_engine.name == 'sqlite': + return 0 + else: + raise Exception( 'Unable to convert data for unknown database type: %s' % db ) + +def upgrade( migrate_engine ): + print __doc__ + metadata.bind = migrate_engine + metadata.reflect() + # Create the new repository_role_association table. + try: + RepositoryRoleAssociation_table.create() + except Exception, e: + print str(e) + log.debug( "Creating repository_role_association table failed: %s" % str( e ) ) + # Select the list of repositories and associated public user names for their owners. + user_ids = [] + repository_ids = [] + role_names = [] + cmd = 'SELECT repository.id, repository.name, repository.user_id, galaxy_user.username FROM repository, galaxy_user WHERE repository.user_id = galaxy_user.id;' + for row in migrate_engine.execute( cmd ): + repository_id = row[ 0 ] + name = row[ 1 ] + user_id = row[ 2 ] + username = row[ 3 ] + repository_ids.append( int( repository_id ) ) + role_names.append( '%s_%s_admin' % ( str( name ), str( username ) ) ) + user_ids.append( int( user_id ) ) + # Insert a new record into the role table for each new role. + for tup in zip( repository_ids, user_ids, role_names ): + repository_id, user_id, role_name = tup + cmd = "INSERT INTO role VALUES (" + cmd += "%s, " % nextval( migrate_engine, 'role' ) + cmd += "%s, " % localtimestamp( migrate_engine ) + cmd += "%s, " % localtimestamp( migrate_engine ) + cmd += "'%s', " % role_name + cmd += "'A user or group member with this role can administer this repository.', " + cmd += "'%s', " % ROLE_TYPE + cmd += "%s" % boolean_false( migrate_engine ) + cmd += ");" + migrate_engine.execute( cmd ) + # Get the id of the new role. + cmd = "SELECT id FROM role WHERE name = '%s' and type = '%s';" % ( role_name, ROLE_TYPE ) + row = migrate_engine.execute( cmd ).fetchone() + if row: + role_id = row[ 0 ] + else: + role_id = None + if role_id: + # Create a repository_role_association record to associate the repository with the new role. + cmd = "INSERT INTO repository_role_association VALUES (" + cmd += "%s, " % nextval( migrate_engine, 'repository_role_association' ) + cmd += "%d, " % int( repository_id ) + cmd += "%d, " % int( role_id ) + cmd += "%s, " % localtimestamp( migrate_engine ) + cmd += "%s " % localtimestamp( migrate_engine ) + cmd += ");" + migrate_engine.execute( cmd ) + # Create a user_role_association record to associate the repository owner with the new role. + cmd = "INSERT INTO user_role_association VALUES (" + cmd += "%s, " % nextval( migrate_engine, 'user_role_association' ) + cmd += "%d, " % int( user_id ) + cmd += "%d, " % int( role_id ) + cmd += "%s, " % localtimestamp( migrate_engine ) + cmd += "%s " % localtimestamp( migrate_engine ) + cmd += ");" + migrate_engine.execute( cmd ) + +def downgrade( migrate_engine ): + metadata.bind = migrate_engine + metadata.reflect() + # Determine the list of roles to delete by first selecting the list of repositories and associated + # public user names for their owners. + role_names = [] + cmd = 'SELECT name, username FROM repository, galaxy_user WHERE repository.user_id = galaxy_user.id;' + for row in migrate_engine.execute( cmd ): + name = row[ 0 ] + username = row[ 1 ] + role_names.append( '%s_%s_admin' % ( str( name ), str( username ) ) ) + # Delete each role as well as all users associated with each role. + for role_name in role_names: + # Select the id of the record associated with the current role_name from the role table. + cmd = "SELECT id, name FROM role WHERE name = '%s';" % role_name + row = migrate_engine.execute( cmd ).fetchone() + if row: + role_id = row[ 0 ] + else: + role_id = None + if role_id: + # Delete all user_role_association records for the current role. + cmd = "DELETE FROM user_role_association WHERE role_id = %d;" % int( role_id ) + migrate_engine.execute( cmd ) + # Delete all repository_role_association records for the current role. + cmd = "DELETE FROM repository_role_association WHERE role_id = %d;" % int( role_id ) + migrate_engine.execute( cmd ) + # Delete the role from the role table. + cmd = "DELETE FROM role WHERE id = %d;" % int( role_id ) + migrate_engine.execute( cmd ) + # Drop the repository_role_association table. + try: + RepositoryRoleAssociation_table = Table( "repository_role_association", metadata, autoload=True ) + except NoSuchTableError: + log.debug( "Failed loading table repository_role_association" ) + try: + RepositoryRoleAssociation_table.drop() + except Exception, e: + log.debug( "Dropping repository_role_association table failed: %s" % str( e ) ) diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 lib/galaxy/webapps/tool_shed/security/__init__.py --- a/lib/galaxy/webapps/tool_shed/security/__init__.py +++ b/lib/galaxy/webapps/tool_shed/security/__init__.py @@ -152,7 +152,13 @@ self.model.Role.table.c.type == self.model.Role.types.SYSTEM ) ) \ .first() - def set_entity_group_associations( self, groups=[], users=[], roles=[], delete_existing_assocs=True ): + def set_entity_group_associations( self, groups=None, users=None, roles=None, delete_existing_assocs=True ): + if groups is None: + groups = [] + if users is None: + users = [] + if roles is None: + roles = [] for group in groups: if delete_existing_assocs: for a in group.roles + group.users: @@ -163,7 +169,15 @@ for user in users: self.associate_components( group=group, user=user ) - def set_entity_role_associations( self, roles=[], users=[], groups=[], delete_existing_assocs=True ): + def set_entity_role_associations( self, roles=None, users=None, groups=None, repositories=None, delete_existing_assocs=True ): + if roles is None: + roles = [] + if users is None: + users = [] + if groups is None: + groups = [] + if repositories is None: + repositories = [] for role in roles: if delete_existing_assocs: for a in role.users + role.groups: @@ -174,7 +188,13 @@ for group in groups: self.associate_components( group=group, role=role ) - def set_entity_user_associations( self, users=[], roles=[], groups=[], delete_existing_assocs=True ): + def set_entity_user_associations( self, users=None, roles=None, groups=None, delete_existing_assocs=True ): + if users is None: + users = [] + if roles is None: + roles = [] + if groups is None: + groups = [] for user in users: if delete_existing_assocs: for a in user.non_private_roles + user.groups: @@ -193,6 +213,29 @@ return user.username in listify( repository.allow_push( app ) ) return False + def user_can_administer_repository( self, user, repository ): + """Return True if the received user can administer the received repository.""" + if user: + if repository: + repository_admin_role = repository.admin_role + for rra in repository.roles: + role = rra.role + if role.id == repository_admin_role.id: + # We have the repository's admin role, so see if the user is associated with it. + for ura in role.users: + role_member = ura.user + if role_member.id == user.id: + return True + # The user is not directly associated with the role, so see if they are a member + # of a group that is associated with the role. + for gra in role.groups: + group = gra.group + for uga in group.members: + member = uga.user + if member.id == user.id: + return True + return False + def user_can_import_repository_archive( self, user, archive_owner ): # This method should be called only if the current user is not an admin. if user.username == archive_owner: diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 lib/tool_shed/grids/admin_grids.py --- a/lib/tool_shed/grids/admin_grids.py +++ b/lib/tool_shed/grids/admin_grids.py @@ -149,20 +149,20 @@ class NameColumn( grids.TextColumn ): def get_value( self, trans, grid, role ): - return role.name + return str( role.name ) class DescriptionColumn( grids.TextColumn ): def get_value( self, trans, grid, role ): if role.description: - return role.description + return str( role.description ) return '' class TypeColumn( grids.TextColumn ): def get_value( self, trans, grid, role ): - return role.type + return str( role.type ) class StatusColumn( grids.GridColumn ): @@ -181,6 +181,14 @@ return 0 + class RepositoriesColumn( grids.GridColumn ): + + def get_value( self, trans, grid, role ): + if role.repositories: + return len( role.repositories ) + return 0 + + class UsersColumn( grids.GridColumn ): def get_value( self, trans, grid, role ): @@ -195,20 +203,16 @@ columns = [ NameColumn( "Name", key="name", - link=( lambda item: dict( operation="Manage users and groups", id=item.id ) ), + link=( lambda item: dict( operation="Manage role associations", id=item.id ) ), attach_popup=True, filterable="advanced" ), DescriptionColumn( "Description", key='description', attach_popup=False, filterable="advanced" ), - TypeColumn( "Type", - key='type', - attach_popup=False, - filterable="advanced" ), GroupsColumn( "Groups", attach_popup=False ), + RepositoriesColumn( "Repositories", attach_popup=False ), UsersColumn( "Users", attach_popup=False ), - StatusColumn( "Status", attach_popup=False ), # Columns that are valid for filtering but are not visible. grids.DeletedColumn( "Deleted", key="deleted", @@ -224,20 +228,23 @@ grids.GridAction( "Add new role", dict( controller='admin', action='roles', operation='create' ) ) ] + # Repository admin roles currently do not have any operations since they are managed automatically based + # on other events. For example, if a repository is renamed, its associated admin role is automatically + # renamed accordingly and if a repository is deleted its associated admin role is automatically deleted. operations = [ grids.GridOperation( "Rename", - condition=( lambda item: not item.deleted ), + condition=( lambda item: not item.deleted and not item.is_repository_admin_role ), allow_multiple=False, url_args=dict( action="rename_role" ) ), grids.GridOperation( "Delete", - condition=( lambda item: not item.deleted ), + condition=( lambda item: not item.deleted and not item.is_repository_admin_role ), allow_multiple=True, url_args=dict( action="mark_role_deleted" ) ), grids.GridOperation( "Undelete", - condition=( lambda item: item.deleted ), + condition=( lambda item: item.deleted and not item.is_repository_admin_role ), allow_multiple=True, url_args=dict( action="undelete_role" ) ), grids.GridOperation( "Purge", - condition=( lambda item: item.deleted ), + condition=( lambda item: item.deleted and not item.is_repository_admin_role ), allow_multiple=True, url_args=dict( action="purge_role" ) ) ] standard_filters = [ diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 lib/tool_shed/grids/repository_grids.py --- a/lib/tool_shed/grids/repository_grids.py +++ b/lib/tool_shed/grids/repository_grids.py @@ -555,6 +555,52 @@ .outerjoin( model.RepositoryCategoryAssociation.table ) \ .outerjoin( model.Category.table ) +class RepositoriesICanAdministerGrid( RepositoryGrid ): + title = "Repositories I can administer" + columns = [ + RepositoryGrid.NameColumn( "Name", + key="name", + link=( lambda item: dict( operation="view_or_manage_repository", id=item.id ) ), + attach_popup=False ), + RepositoryGrid.UserColumn( "Owner" ), + RepositoryGrid.MetadataRevisionColumn( "Metadata<br/>Revisions" ), + RepositoryGrid.ToolsFunctionallyCorrectColumn( "Tools<br/>Verified" ), + RepositoryGrid.DeprecatedColumn( "Deprecated" ) + ] + columns.append( grids.MulticolFilterColumn( "Search repository name", + cols_to_filter=[ columns[0] ], + key="free-text-search", + visible=False, + filterable="standard" ) ) + operations = [] + use_paging = False + + def build_initial_query( self, trans, **kwd ): + """ + Retrieve all repositories for which the current user has been granted administrative privileges. + """ + current_user = trans.user + # Build up an or-based clause list containing role table records. + clause_list = [] + # Include each of the user's roles. + for ura in current_user.roles: + clause_list.append( model.Role.table.c.id == ura.role_id ) + # Include each role associated with each group of which the user is a member. + for uga in current_user.groups: + group = uga.group + for gra in group.roles: + clause_list.append( model.Role.table.c.id == gra.role_id ) + # Filter out repositories for which the user does not have the administrative role either directly + # via a role association or indirectly via a group -> role association. + return trans.sa_session.query( model.Repository ) \ + .filter( model.Repository.table.c.deleted == False ) \ + .outerjoin( model.RepositoryRoleAssociation.table ) \ + .outerjoin( model.Role.table ) \ + .filter( or_( *clause_list ) ) \ + .join( model.User.table ) \ + .outerjoin( model.RepositoryCategoryAssociation.table ) \ + .outerjoin( model.Category.table ) + class RepositoriesMissingToolTestComponentsGrid( RepositoryGrid ): # This grid displays only the latest installable revision of each repository. diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 lib/tool_shed/util/repository_maintenance_util.py --- a/lib/tool_shed/util/repository_maintenance_util.py +++ b/lib/tool_shed/util/repository_maintenance_util.py @@ -4,6 +4,7 @@ import re import tool_shed.util.shed_util_common as suc from tool_shed.util import import_util +from galaxy import util from galaxy.web.form_builder import build_select_field from galaxy.webapps.tool_shed.model import directory_hash_id @@ -69,6 +70,8 @@ # Flush to get the id. trans.sa_session.add( repository ) trans.sa_session.flush() + # Create an admin role for the repository. + repository_admin_role = create_repository_admin_role( trans, repository ) # Determine the repository's repo_path on disk. dir = os.path.join( trans.app.config.file_path, *directory_hash_id( repository.id ) ) # Create directory if it does not exist. @@ -100,10 +103,28 @@ message = "Repository <b>%s</b> has been created." % str( repository.name ) return repository, message +def create_repository_admin_role( trans, repository ): + """ + Create a new role with name-spaced name based on the repository name and its owner's public user + name. This will ensure that the tole name is unique. + """ + name = get_repository_admin_role_name( str( repository.name ), str( repository.user.username ) ) + description = 'A user or group member with this role can administer this repository.' + role = trans.app.model.Role( name=name, description=description, type=trans.app.model.Role.types.SYSTEM ) + trans.sa_session.add( role ) + trans.sa_session.flush() + # Associate the role with the repository owner. + ura = trans.model.UserRoleAssociation( repository.user, role ) + # Associate the role with the repository. + rra = trans.model.RepositoryRoleAssociation( repository, role ) + trans.sa_session.add( rra ) + trans.sa_session.flush() + return role + def create_repository_and_import_archive( trans, repository_archive_dict, import_results_tups ): """ - Create a new repository in the tool shed and populate it with the contents of a gzip compressed tar archive - that was exported as part or all of the contents of a capsule. + Create a new repository in the tool shed and populate it with the contents of a gzip compressed + tar archive that was exported as part or all of the contents of a capsule. """ results_message = '' name = repository_archive_dict.get( 'name', None ) @@ -166,6 +187,66 @@ import_results_tups.append( ( ok, ( str( name ), str( username ) ), results_message ) ) return import_results_tups +def get_repository_admin_role_name( repository_name, repository_owner ): + return '%s_%s_admin' % ( str( repository_name ), str( repository_owner ) ) + +def get_role_by_id( trans, role_id ): + """Get a Role from the database by id.""" + return trans.sa_session.query( trans.model.Role ).get( trans.security.decode_id( role_id ) ) + +def handle_role_associations( trans, role, repository, **kwd ): + message = kwd.get( 'message', '' ) + status = kwd.get( 'status', 'done' ) + repository_owner = repository.user + if kwd.get( 'manage_role_associations_button', False ): + in_users_list = util.listify( kwd.get( 'in_users', [] ) ) + in_users = [ trans.sa_session.query( trans.app.model.User ).get( x ) for x in in_users_list ] + # Make sure the repository owner is always associated with the repostory's admin role. + owner_associated = False + for user in in_users: + if user.id == repository_owner.id: + owner_associated = True + break + if not owner_associated: + in_users.append( repository_owner ) + message += "The repository owner must always be associated with the repository's administrator role. " + status = 'error' + in_groups_list = util.listify( kwd.get( 'in_groups', [] ) ) + in_groups = [ trans.sa_session.query( trans.app.model.Group ).get( x ) for x in in_groups_list ] + in_repositories = [ repository ] + trans.app.security_agent.set_entity_role_associations( roles=[ role ], + users=in_users, + groups=in_groups, + repositories=in_repositories ) + trans.sa_session.refresh( role ) + message += "Role <b>%s</b> has been associated with %d users, %d groups and %d repositories. " % \ + ( str( role.name ), len( in_users ), len( in_groups ), len( in_repositories ) ) + in_users = [] + out_users = [] + in_groups = [] + out_groups = [] + for user in trans.sa_session.query( trans.app.model.User ) \ + .filter( trans.app.model.User.table.c.deleted==False ) \ + .order_by( trans.app.model.User.table.c.email ): + if user in [ x.user for x in role.users ]: + in_users.append( ( user.id, user.email ) ) + else: + out_users.append( ( user.id, user.email ) ) + for group in trans.sa_session.query( trans.app.model.Group ) \ + .filter( trans.app.model.Group.table.c.deleted==False ) \ + .order_by( trans.app.model.Group.table.c.name ): + if group in [ x.group for x in role.groups ]: + in_groups.append( ( group.id, group.name ) ) + else: + out_groups.append( ( group.id, group.name ) ) + associations_dict = dict( in_users=in_users, + out_users=out_users, + in_groups=in_groups, + out_groups=out_groups, + message=message, + status=status ) + return associations_dict + def validate_repository_name( app, name, user ): # Repository names must be unique for each user, must be at least four characters # in length and must contain only lower-case letters, numbers, and the '_' character. diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 templates/webapps/tool_shed/common/repository_actions_menu.mako --- a/templates/webapps/tool_shed/common/repository_actions_menu.mako +++ b/templates/webapps/tool_shed/common/repository_actions_menu.mako @@ -12,6 +12,11 @@ is_admin = trans.user_is_admin() + if is_admin or trans.app.security_agent.user_can_administer_repository( trans.user, repository ): + can_administer = True + else: + can_administer = False + if repository.deprecated: is_deprecated = True else: @@ -54,7 +59,7 @@ else: can_download = False - if ( is_admin or can_push ) and not repository.deleted and not repository.deprecated and not is_new: + if ( can_administer or can_push ) and not repository.deleted and not repository.deprecated and not is_new: can_reset_all_metadata = True else: can_reset_all_metadata = False @@ -109,11 +114,6 @@ else: can_undeprecate = False - if is_admin or repository.user == trans.user: - can_manage = True - else: - can_manage = False - can_view_change_log = not is_new if can_push: @@ -147,7 +147,7 @@ %if can_upload: <a class="action-button" target="galaxy_main" href="${h.url_for( controller='upload', action='upload', repository_id=trans.security.encode_id( repository.id ) )}">Upload files to repository</a> %endif - %if can_manage: + %if can_administer: <a class="action-button" target="galaxy_main" href="${h.url_for( controller='repository', action='manage_repository', id=trans.app.security.encode_id( repository.id ), changeset_revision=repository.tip( trans.app ) )}">Manage repository</a> %else: <a class="action-button" target="galaxy_main" href="${h.url_for( controller='repository', action='view_repository', id=trans.app.security.encode_id( repository.id ), changeset_revision=repository.tip( trans.app ) )}">View repository</a> @@ -173,6 +173,9 @@ %if can_undeprecate: <a class="action-button" target="galaxy_main" href="${h.url_for( controller='repository', action='deprecate', id=trans.security.encode_id( repository.id ), mark_deprecated=False )}">Mark repository as not deprecated</a> %endif + %if can_administer: + <a class="action-button" target="galaxy_main" href="${h.url_for( controller='repository', action='manage_repository_admins', id=trans.security.encode_id( repository.id ) )}">Manage repository administrators</a> + %endif %if can_download: %if changeset_revision is not None: <a class="action-button" href="${h.url_for( controller='repository', action='export', repository_id=trans.app.security.encode_id( repository.id ), changeset_revision=changeset_revision )}">Export this revision</a> diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 templates/webapps/tool_shed/index.mako --- a/templates/webapps/tool_shed/index.mako +++ b/templates/webapps/tool_shed/index.mako @@ -97,7 +97,7 @@ <a target="galaxy_main" href="${h.url_for( controller='repository', action='browse_categories' )}">Browse by category</a></div> %if trans.user: - %if trans.user.active_repositories: + %if trans.user.active_repositories or can_administer_repositories: <div class="toolSectionPad"></div><div class="toolSectionTitle"> Repositories I Can Change @@ -105,6 +105,11 @@ <div class="toolTitle"><a target="galaxy_main" href="${h.url_for( controller='repository', action='browse_repositories_i_own' )}">Repositories I own</a></div> + %if can_administer_repositories: + <div class="toolTitle"> + <a target="galaxy_main" href="${h.url_for( controller='repository', action='browse_repositories_i_can_administer' )}">Repositories I can administer</a> + </div> + %endif %if has_reviewed_repositories: <div class="toolTitle"><a target="galaxy_main" href="${h.url_for( controller='repository', action='browse_repositories', operation='reviewed_repositories_i_own' )}">Reviewed repositories I own</a> diff -r e2eb861e6c0b0cbaf8a1a1e091d3792724495b69 -r 9414cc43797afb77190abcbda0fdf173a6a100f3 templates/webapps/tool_shed/role/role.mako --- /dev/null +++ b/templates/webapps/tool_shed/role/role.mako @@ -0,0 +1,136 @@ +<%inherit file="/base.mako"/> +<%namespace file="/message.mako" import="render_msg" /> +<%namespace file="/webapps/tool_shed/common/repository_actions_menu.mako" import="render_tool_shed_repository_actions" /> + +<%def name="javascripts()"> + ${parent.javascripts()} + <script type="text/javascript"> + $(function(){ + $("input:text:first").focus(); + }) + </script> +</%def> + +<%def name="render_select( name, options )"> + <select name="${name}" id="${name}" style="min-width: 250px; height: 150px;" multiple> + %for option in options: + <option value="${option[0]}">${option[1]}</option> + %endfor + </select> +</%def> + +<script type="text/javascript"> +$().ready(function() { + $('#repositories_add_button').click(function() { + return !$('#out_repositories option:selected').remove().appendTo('#in_repositories'); + }); + $('#repositories_remove_button').click(function() { + return !$('#in_repositories option:selected').remove().appendTo('#out_repositories'); + }); + $('#users_add_button').click(function() { + return !$('#out_users option:selected').remove().appendTo('#in_users'); + }); + $('#users_remove_button').click(function() { + return !$('#in_users option:selected').remove().appendTo('#out_users'); + }); + $('#groups_add_button').click(function() { + return !$('#out_groups option:selected').remove().appendTo('#in_groups'); + }); + $('#groups_remove_button').click(function() { + return !$('#in_groups option:selected').remove().appendTo('#out_groups'); + }); + $('form#manage_role_associations').submit(function() { + $('#in_repositories option').each(function(i) { + $(this).attr("selected", "selected"); + }); + $('#in_users option').each(function(i) { + $(this).attr("selected", "selected"); + }); + $('#in_groups option').each(function(i) { + $(this).attr("selected", "selected"); + }); + }); +}); +</script> + +<% + if trans.user_is_admin() and in_admin_controller: + render_for_admin = True + else: + render_for_admin = False +%> + +%if not render_for_admin: + ${render_tool_shed_repository_actions( repository, metadata=metadata, changeset_revision=changeset_revision )} +%endif + +%if message: + ${render_msg( message, status )} +%endif + +<div class="warningmessage"> + <b>${role.name}</b> is the administrator role for the repository <b>${repository.name}</b> owned by + <b>${repository.user.username}</b>. ${role.description} +</div> + +<div class="toolForm"> + <div class="toolFormTitle">Manage users and groups associated with role <b>${role.name}</b></div> + <div class="toolFormBody"> + % if not render_for_admin: + <div class="form-row"> + <label>Repository name:</label> + ${repository.name} + <div style="clear: both"></div> + </div> + <div class="form-row"> + <label>Repository owner:</label> + ${repository.user.username} + <div style="clear: both"></div> + </div> + %endif + <% + if render_for_admin: + controller_module = 'admin' + controller_method = 'manage_role_associations' + id_param = trans.security.encode_id( role.id ) + else: + controller_module = 'repository' + controller_method = 'manage_repository_admins' + id_param = trans.security.encode_id( repository.id ) + %> + <form name="manage_role_associations" id="manage_role_associations" action="${h.url_for( controller=controller_module, action=controller_method, id=id_param )}" method="post" > + <div class="form-row"> + <div style="float: left; margin-right: 10px;"> + <label>Users associated with '${role.name}'</label> + ${render_select( "in_users", in_users )}<br/> + <input type="submit" id="users_remove_button" value=">>"/> + <div style="clear: both"></div> + </div> + <div> + <label>Users not associated with '${role.name}'</label> + ${render_select( "out_users", out_users )}<br/> + <input type="submit" id="users_add_button" value="<<"/> + <div style="clear: both"></div> + </div> + </div> + <div class="form-row"> + <div style="float: left; margin-right: 10px;"> + <label>Groups associated with '${role.name}'</label> + ${render_select( "in_groups", in_groups )}<br/> + <input type="submit" id="groups_remove_button" value=">>"/> + <div style="clear: both"></div> + </div> + <div> + <label>Groups not associated with '${role.name}'</label> + ${render_select( "out_groups", out_groups )}<br/> + <input type="submit" id="groups_add_button" value="<<"/> + <div style="clear: both"></div> + </div> + </div> + <div style="clear: both"></div> + <div class="form-row"> + <input type="submit" name="manage_role_associations_button" value="Save"/> + </div> + </form> + </div> +</div> Repository URL: https://bitbucket.org/galaxy/galaxy-central/ -- This is a commit notification from bitbucket.org. You are receiving this because you have the service enabled, addressing the recipient of this email.