[hg] galaxy 3698: Community space fixes and enhancements: separa...
details: http://www.bx.psu.edu/hg/galaxy/rev/83102f27fd1d changeset: 3698:83102f27fd1d user: Greg Von Kuster <greg@bx.psu.edu> date: Mon Apr 26 17:02:37 2010 -0400 description: Community space fixes and enhancements: separate the ToolListGrids into both controllers, add a new common controller for common methods across admin and tool_browser, first pass of tool category and tool state features. diffstat: lib/galaxy/web/controllers/requests_admin.py | 2 +- lib/galaxy/webapps/community/controllers/admin.py | 137 +++++++- lib/galaxy/webapps/community/controllers/common.py | 166 ++++++++++ lib/galaxy/webapps/community/controllers/tool_browser.py | 119 +++---- lib/galaxy/webapps/community/controllers/upload.py | 26 +- lib/galaxy/webapps/community/model/__init__.py | 68 ++- lib/galaxy/webapps/community/model/mapping.py | 28 +- lib/galaxy/webapps/community/model/migrate/versions/0001_initial_tables.py | 14 +- templates/webapps/community/admin/category/category_create.mako | 35 -- templates/webapps/community/admin/category/category_rename.mako | 44 -- templates/webapps/community/admin/category/grid.mako | 1 - templates/webapps/community/admin/category/role.mako | 118 ------- templates/webapps/community/admin/index.mako | 2 +- templates/webapps/community/category/add_to_category.mako | 24 + templates/webapps/community/category/create_category.mako | 35 ++ templates/webapps/community/category/grid.mako | 1 + templates/webapps/community/category/rename_category.mako | 44 ++ templates/webapps/community/tool/edit_tool.mako | 99 ++--- templates/webapps/community/upload/upload.mako | 15 + 19 files changed, 617 insertions(+), 361 deletions(-) diffs (1285 lines): diff -r 054527415fac -r 83102f27fd1d lib/galaxy/web/controllers/requests_admin.py --- a/lib/galaxy/web/controllers/requests_admin.py Mon Apr 26 15:40:52 2010 -0400 +++ b/lib/galaxy/web/controllers/requests_admin.py Mon Apr 26 17:02:37 2010 -0400 @@ -48,7 +48,7 @@ elif request.state() == request.states.COMPLETE: return '<div class="count-box state-color-ok">%s</div>' % request.state() return request.state() - def filter( self, db_session, user, query, column_filter ): + def filter( self, trans, user, query, column_filter ): """ Modify query to filter request by state. """ if column_filter == "All": return query diff -r 054527415fac -r 83102f27fd1d lib/galaxy/webapps/community/controllers/admin.py --- a/lib/galaxy/webapps/community/controllers/admin.py Mon Apr 26 15:40:52 2010 -0400 +++ b/lib/galaxy/webapps/community/controllers/admin.py Mon Apr 26 17:02:37 2010 -0400 @@ -1,8 +1,8 @@ from galaxy.web.base.controller import * -#from galaxy.web.controllers.admin import get_user, get_group, get_role from galaxy.webapps.community import model from galaxy.model.orm import * from galaxy.web.framework.helpers import time_ago, iff, grids +from common import get_categories, get_category import logging log = logging.getLogger( __name__ ) @@ -294,7 +294,7 @@ webapp = "community" title = "Categories" model_class = model.Category - template='/webapps/community/admin/category/grid.mako' + template='/webapps/community/category/grid.mako' default_sort_key = "name" columns = [ NameColumn( "Name", @@ -346,15 +346,133 @@ def build_initial_query( self, session ): return session.query( self.model_class ) +# States for passing messages +SUCCESS, INFO, WARNING, ERROR = "done", "info", "warning", "error" + +class ToolListGrid( grids.Grid ): + class NameColumn( grids.TextColumn ): + def get_value( self, trans, grid, tool ): + return tool.name + class CategoryColumn( grids.TextColumn ): + def get_value( self, trans, grid, tool ): + if tool.categories: + rval = '' + for tca in tool.categories: + rval = '%s%s<br/>' % ( rval, tca.category.name ) + return rval + return 'not set' + class StateColumn( grids.GridColumn ): + def get_value( self, trans, grid, tool ): + state = tool.state() + if state == tool.states.NEW: + return '<div class="count-box state-color-queued">%s</div>' % state + if state == tool.states.WAITING: + return '<div class="count-box state-color-running">%s</div>' % state + if state == tool.states.APPROVED: + return '<div class="count-box state-color-ok">%s</div>' % state + if state == tool.states.REJECTED or state == tool.states.ERROR: + return '<div class="count-box state-color-error">%s</div>' % state + return state + def get_accepted_filters( self ): + """ Returns a list of accepted filters for this column.""" + accepted_filter_labels_and_vals = [ model.Tool.states.NEW, + model.Tool.states.WAITING, + model.Tool.states.APPROVED, + model.Tool.states.REJECTED, + model.Tool.states.DELETED, + "All" ] + accepted_filters = [] + for val in accepted_filter_labels_and_vals: + label = val.lower() + args = { self.key: val } + accepted_filters.append( grids.GridColumnFilter( label, args ) ) + return accepted_filters + class UserColumn( grids.TextColumn ): + def get_value( self, trans, grid, tool ): + return tool.user.email + # Grid definition + title = "Tools" + model_class = model.Tool + template='/webapps/community/tool/grid.mako' + default_sort_key = "name" + columns = [ + NameColumn( "Name", + key="name", + model_class=model.Tool, + link=( lambda item: dict( operation="Edit Tool", id=item.id, webapp="community" ) ), + attach_popup=True, + filterable="advanced" ), + CategoryColumn( "Category", + key="category", + model_class=model.Category, + attach_popup=False, + filterable="advanced" ), + StateColumn( "State", + key="state", + model_class=model.Event, + attach_popup=False, + filterable="advanced" ), + # Columns that are valid for filtering but are not visible. + grids.DeletedColumn( "Deleted", key="deleted", visible=False, filterable="advanced" ) + ] + columns.append( grids.MulticolFilterColumn( "Search", + cols_to_filter=[ columns[0], columns[1] ], + key="free-text-search", + visible=False, + filterable="standard" ) ) + global_actions = [ + grids.GridAction( "Upload tool", dict( controller='upload', action='upload', type='tool' ) ) + ] + operations = [ + grids.GridOperation( "Add to category", + condition=( lambda item: not item.deleted ), + allow_multiple=False, + url_args=dict( controller="common", action="add_category", cntrller="admin", webapp="community" ) ), + grids.GridOperation( "Remove from category", + condition=( lambda item: not item.deleted ), + allow_multiple=False, + url_args=dict( controller="common", action="remove_category", cntrller="admin", webapp="community" ) ), + grids.GridOperation( "View versions", condition=( lambda item: not item.deleted ), allow_multiple=False ) + ] + standard_filters = [ + grids.GridColumnFilter( "Deleted", args=dict( deleted=True ) ), + grids.GridColumnFilter( "All", args=dict( deleted='All' ) ) + ] + default_filter = dict( name="All", deleted="False" ) + num_rows_per_page = 50 + preserve_state = False + use_paging = True + def build_initial_query( self, session ): + return session.query( self.model_class ) + def apply_default_filter( self, trans, query, **kwargs ): + return query.filter( self.model_class.deleted==False ) + class AdminCommunity( BaseController, Admin ): user_list_grid = UserListGrid() role_list_grid = RoleListGrid() group_list_grid = GroupListGrid() category_list_grid = CategoryListGrid() + tool_list_grid = ToolListGrid() @web.expose @web.require_admin + def browse_tools( self, trans, **kwargs ): + if 'operation' in kwargs: + operation = kwargs['operation'].lower() + if operation == "browse": + return trans.response.send_redirect( web.url_for( controller='tool_browser', + action='browse_tool', + **kwargs ) ) + elif operation == "edit tool": + return trans.response.send_redirect( web.url_for( controller='common', + action='edit_tool', + cntrller='admin', + **kwargs ) ) + # Render the list view + return self.tool_list_grid( trans, **kwargs ) + @web.expose + @web.require_admin def categories( self, trans, **kwargs ): if 'operation' in kwargs: operation = kwargs['operation'].lower() @@ -401,7 +519,7 @@ webapp=webapp, message=util.sanitize_text( message ), status='error' ) ) - return trans.fill_template( '/webapps/community/admin/category/category_create.mako', + return trans.fill_template( '/webapps/community/category/create_category.mako', webapp=webapp, message=message, status=status ) @@ -442,7 +560,7 @@ webapp=webapp, message=util.sanitize_text( message ), status='done' ) ) - return trans.fill_template( '/webapps/community/admin/category/category_rename.mako', + return trans.fill_template( '/webapps/community/category/rename_category.mako', category=category, webapp=webapp, message=message, @@ -546,14 +664,3 @@ webapp=webapp, message=util.sanitize_text( message ), status='done' ) ) - -## ---- Utility methods ------------------------------------------------------- - -def get_category( trans, id ): - """Get a User from the database by id.""" - # Load user from database - id = trans.security.decode_id( id ) - category = trans.sa_session.query( trans.model.Category ).get( id ) - if not category: - return trans.show_error_message( "Category not found for id (%s)" % str( id ) ) - return category diff -r 054527415fac -r 83102f27fd1d lib/galaxy/webapps/community/controllers/common.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/lib/galaxy/webapps/community/controllers/common.py Mon Apr 26 17:02:37 2010 -0400 @@ -0,0 +1,166 @@ +from galaxy.web.base.controller import * +#from galaxy.web.controllers.admin import get_user, get_group, get_role +from galaxy.webapps.community import model +from galaxy.model.orm import * +from galaxy.web.framework.helpers import time_ago, iff, grids +from galaxy.web.form_builder import SelectField +import logging +log = logging.getLogger( __name__ ) + +class CommunityCommon( BaseController ): + @web.expose + def edit_tool( self, trans, cntrller, id=None, **kwd ): + params = util.Params( kwd ) + message = util.restore_text( params.get( 'message', '' ) ) + status = params.get( 'status', 'done' ) + # Get the tool + tool = None + if id is not None: + encoded_id = id + id = trans.app.security.decode_id( id ) + tool = trans.sa_session.query( trans.model.Tool ).get( id ) + if tool is None: + return trans.response.send_redirect( web.url_for( controller=cntrller, + action='browse_tools', + message='Please select a Tool to edit (the tool ID provided was invalid)', + status='error' ) ) + if params.save_button and ( params.file_data != '' or params.url != '' ): + # TODO: call the upload method in the upload controller. + message = 'Uploading new version not implemented' + status = 'error' + elif params.save_button: + tool.user_description = util.restore_text( params.description ) + categories = [] + set_categories( trans, tool, util.listify( params.category_id ) ) + trans.sa_session.add( tool ) + trans.sa_session.flush() + return trans.response.send_redirect( web.url_for( controller=cntrller, + action='browse_tools', + message="Saved categories and description for tool '%s'" % tool.name, + status='done' ) ) + categories = trans.sa_session.query( trans.model.Category ).order_by( trans.model.Category.table.c.name ).all() + return trans.fill_template( '/webapps/community/tool/edit_tool.mako', + cntrller=cntrller, + encoded_id = encoded_id, + tool=tool, + categories=categories, + message=message, + status=status ) + @web.expose + def view_tool( self, trans, cntrller, id=None, **kwd ): + params = util.Params( kwd ) + message = util.restore_text( params.get( 'message', '' ) ) + status = params.get( 'status', 'done' ) + # Get the tool + tool = None + if id is not None: + id = trans.app.security.decode_id( id ) + tool = trans.sa_session.query( trans.model.Tool ).get( id ) + if tool is None: + return trans.response.send_redirect( web.url_for( controller=cntrller, + action='browse_tools', + message='Please select a Tool to edit (the tool ID provided was invalid)', + status='error' ) ) + return trans.fill_template( '/webapps/community/tool/view_tool.mako', + tool=tool, + message=message, + status=status ) + @web.expose + def add_category( self, trans, cntrller, **kwd ): + # TODO: we currently assume we are setting a tool category, so this method may need + # tweaking if / when we decide to set history or workflow categories + params = util.Params( kwd ) + message = util.restore_text( params.get( 'message', '' ) ) + status = params.get( 'status', 'done' ) + use_panels = util.string_as_bool( params.get( 'use_panels', False ) ) + id = params.get( 'id', None ) + # TODO: redirect if no id + tool = trans.sa_session.query( trans.model.Tool ).get( trans.security.decode_id( id ) ) + if params.get( 'add_category_button', False ): + category_ids = util.listify( params.get( 'category_id', '' ) ) + # TODO: redirect if no category_id + message = "The tool '%s' has been added to the categories: " % ( tool.name ) + for category_id in category_ids: + category = trans.sa_session.query( trans.model.Category ).get( trans.security.decode_id( category_id ) ) + tca = trans.app.model.ToolCategoryAssociation( tool, category ) + trans.sa_session.add( tca ) + trans.sa_session.flush() + message += " %s " % category.name + trans.response.send_redirect( web.url_for( controller=cntrller, + action='browse_tools', + use_panels=use_panels, + cntrller=cntrller, + message=util.sanitize_text( message ), + status=status ) ) + category_select_list = SelectField( 'category_id', multiple=True ) + for category in get_unassociated_categories( trans, tool ): + category_select_list.add_option( category.name, trans.security.encode_id( category.id ) ) + return trans.fill_template( '/webapps/community/category/add_to_category.mako', + cntrller=cntrller, + id=id, + category_select_list=category_select_list, + use_panels=use_panels ) + @web.expose + def remove_category( self, trans, cntrller, **kwd ): + # TODO: we currently assume we are setting a tool category, so this method may need + # tweaking if / when we decide to set history or workflow categories + params = util.Params( kwd ) + message = util.restore_text( params.get( 'message', '' ) ) + status = params.get( 'status', 'done' ) + use_panels = util.string_as_bool( params.get( 'use_panels', False ) ) + id = params.get( 'id', None ) + # TODO: redirect if no id + tool = trans.sa_session.query( trans.model.Tool ).get( trans.security.decode_id( id ) ) + category_id = params.get( 'category_id', None ) + category = trans.sa_session.query( trans.model.Category ).get( trans.security.decode_id( category_id ) ) + # TODO: redirect if no category_id + for tca in tool.categories: + if tca.category == category: + trans.sa_session.delete( tca ) + trans.sa_session.flush() + break + message = "The tool '%s' has been removed from the category '%s'" % ( tool.name, category.name ) + trans.response.send_redirect( web.url_for( controller=cntrller, + action='browse_tools', + use_panels=use_panels, + cntrller=cntrller, + message=util.sanitize_text( message ), + status=status ) ) + +## ---- Utility methods ------------------------------------------------------- + +def get_categories( trans ): + """Get all categories from the database""" + return trans.sa_session.query( trans.model.Category ) \ + .filter( trans.model.Category.table.c.deleted==False ) \ + .order_by( trans.model.Category.table.c.name ) +def get_unassociated_categories( trans, obj ): + """Get all categories from the database that are not associated with obj""" + # TODO: we currently assume we are setting a tool category, so this method may need + # tweaking if / when we decide to set history or workflow categories + associated_categories = [] + for tca in obj.categories: + associated_categories.append( tca.category ) + categories = [] + for category in get_categories( trans ): + if category not in associated_categories: + categories.append( category ) + return categories +def get_category( trans, id ): + """Get a Category from the database by id.""" + # Load user from database + id = trans.security.decode_id( id ) + category = trans.sa_session.query( trans.model.Category ).get( id ) + if not category: + return trans.show_error_message( "Category not found for id (%s)" % str( id ) ) + return category +def set_categories( trans, obj, category_ids, delete_existing_assocs=True ): + if delete_existing_assocs: + for assoc in obj.categories: + trans.sa_session.delete( assoc ) + trans.sa_session.flush() + for category_id in category_ids: + # TODO: we currently assume we are setting a tool category, so this method may need + # tweaking if / when we decide to set history or workflow categories + category = trans.sa_session.query( trans.model.Category ).get( category_id ) + obj.categories.append( trans.model.ToolCategoryAssociation( obj, category ) ) diff -r 054527415fac -r 83102f27fd1d lib/galaxy/webapps/community/controllers/tool_browser.py --- a/lib/galaxy/webapps/community/controllers/tool_browser.py Mon Apr 26 15:40:52 2010 -0400 +++ b/lib/galaxy/webapps/community/controllers/tool_browser.py Mon Apr 26 17:02:37 2010 -0400 @@ -4,6 +4,7 @@ from galaxy.webapps.community import model from galaxy.web.framework.helpers import time_ago, iff, grids from galaxy.model.orm import * +from common import * log = logging.getLogger( __name__ ) @@ -13,15 +14,41 @@ class ToolListGrid( grids.Grid ): class NameColumn( grids.TextColumn ): def get_value( self, trans, grid, tool ): - if tool.name: - return tool.name - return 'not set' + return tool.name class CategoryColumn( grids.TextColumn ): def get_value( self, trans, grid, tool ): - if tool.category: - return tool.category + if tool.categories: + return tool.categories return 'not set' - + class StateColumn( grids.GridColumn ): + def get_value( self, trans, grid, tool ): + state = tool.state() + if state == tool.states.NEW: + return '<div class="count-box state-color-queued">%s</div>' % state + if state == tool.states.WAITING: + return '<div class="count-box state-color-running">%s</div>' % state + if state == tool.states.APPROVED: + return '<div class="count-box state-color-ok">%s</div>' % state + if state == tool.states.REJECTED or state == tool.states.ERROR: + return '<div class="count-box state-color-error">%s</div>' % state + return state + def get_accepted_filters( self ): + """ Returns a list of accepted filters for this column.""" + accepted_filter_labels_and_vals = [ model.Tool.states.NEW, + model.Tool.states.WAITING, + model.Tool.states.APPROVED, + model.Tool.states.REJECTED, + model.Tool.states.DELETED, + "All" ] + accepted_filters = [] + for val in accepted_filter_labels_and_vals: + label = val.lower() + args = { self.key: val } + accepted_filters.append( grids.GridColumnFilter( label, args ) ) + return accepted_filters + class UserColumn( grids.TextColumn ): + def get_value( self, trans, grid, tool ): + return tool.user.email # Grid definition title = "Tools" model_class = model.Tool @@ -31,9 +58,15 @@ NameColumn( "Name", key="name", model_class=model.Tool, - link=( lambda item: dict( operation="View Tool", id=item.id, webapp="community" ) ), - attach_popup=False, + link=( lambda item: dict( operation="Edit Tool", id=item.id, webapp="community" ) ), + attach_popup=True, filterable="advanced" ), + CategoryColumn( "Category", + key="category", + model_class=model.Category, + link=( lambda item: dict( operation="View Tool", id=item.id, webapp="community" ) ), + attach_popup=False, + filterable="advanced" ), # Columns that are valid for filtering but are not visible. grids.DeletedColumn( "Deleted", key="deleted", visible=False, filterable="advanced" ) ] @@ -46,6 +79,14 @@ grids.GridAction( "Upload tool", dict( controller='upload', action='upload', type='tool' ) ) ] operations = [ + grids.GridOperation( "Add to category", + condition=( lambda item: not item.deleted ), + allow_multiple=False, + url_args=dict( controller="common", action="add_category", webapp="community" ) ), + grids.GridOperation( "Remove from category", + condition=( lambda item: not item.deleted ), + allow_multiple=False, + url_args=dict( controller="common", action="remove_category", webapp="community" ) ), grids.GridOperation( "View versions", condition=( lambda item: not item.deleted ), allow_multiple=False ) ] standard_filters = [ @@ -84,8 +125,9 @@ action='view_tool', **kwargs ) ) elif operation == "edit tool": - return trans.response.send_redirect( web.url_for( controller='tool_browser', + return trans.response.send_redirect( web.url_for( controller='common', action='edit_tool', + cntrller='tool_browser', **kwargs ) ) # Render the list view return self.tool_list_grid( trans, **kwargs ) @@ -99,65 +141,6 @@ message=message, status=status ) @web.expose - def view_tool( self, trans, id=None, **kwd ): - params = util.Params( kwd ) - message = util.restore_text( params.get( 'message', '' ) ) - status = params.get( 'status', 'done' ) - # Get the tool - tool = None - if id is not None: - id = trans.app.security.decode_id( id ) - tool = trans.sa_session.query( trans.model.Tool ).get( id ) - if tool is None: - return trans.response.send_redirect( web.url_for( controller='tool_browser', - action='browse_tools', - message='Please select a Tool to edit (the tool ID provided was invalid)', - status='error' ) ) - return trans.fill_template( '/webapps/community/tool/view_tool.mako', - tool=tool, - message=message, - status=status ) - @web.expose - def edit_tool( self, trans, id=None, **kwd ): - params = util.Params( kwd ) - message = util.restore_text( params.get( 'message', '' ) ) - status = params.get( 'status', 'done' ) - # Get the tool - tool = None - if id is not None: - id = trans.app.security.decode_id( id ) - tool = trans.sa_session.query( trans.model.Tool ).get( id ) - if tool is None: - return trans.response.send_redirect( web.url_for( controller='tool_browser', - action='browse_tools', - message='Please select a Tool to edit (the tool ID provided was invalid)', - status='error' ) ) - if tool.user_id != trans.user.id: - return trans.response.send_redirect( web.url_for( controller='tool_browser', - action='view_tool', - message='You are not the owner of this tool and therefore cannot edit it', - status='error' ) ) - if params.save_button and ( params.file_data != '' or params.url != '' ): - # TODO: call the upload method in the upload controller. - message = 'Uploading new version not implemented' - status = 'error' - elif params.save_button: - tool.user_description = util.restore_text( params.description ) - categories = [] - tool.set_categories( trans, util.listify( params.category ) ) - trans.sa_session.add( tool ) - trans.sa_session.flush() - return trans.response.send_redirect( web.url_for( controller='tool_browser', - action='browse_tools', - message='Saved categories and description for %s' % tool.name, - status='done' ) ) - categories = trans.sa_session.query( trans.model.Category ).order_by( trans.model.Category.table.c.name ).all() - return trans.fill_template( '/webapps/community/tool/edit_tool.mako', - tool=tool, - categories=categories, - message=message, - status=status ) - @web.expose def download_tool( self, trans, id=None, **kwd ): params = util.Params( kwd ) tool = None diff -r 054527415fac -r 83102f27fd1d lib/galaxy/webapps/community/controllers/upload.py --- a/lib/galaxy/webapps/community/controllers/upload.py Mon Apr 26 15:40:52 2010 -0400 +++ b/lib/galaxy/webapps/community/controllers/upload.py Mon Apr 26 17:02:37 2010 -0400 @@ -1,9 +1,9 @@ import sys, os, shutil, logging, urllib2 - from galaxy.web.base.controller import * from galaxy.web.framework.helpers import time_ago, iff, grids from galaxy.model.orm import * from galaxy.webapps.community import datatypes +from common import get_categories, get_category log = logging.getLogger( __name__ ) @@ -17,6 +17,7 @@ params = util.Params( kwd ) message = util.restore_text( params.get( 'message', '' ) ) status = params.get( 'status', 'done' ) + category_ids = util.listify( params.get( 'category_id', '' ) ) uploaded_file = None if params.file_data == '' and params.url.strip() == '': message = 'No files were entered on the upload form.' @@ -35,22 +36,36 @@ if params.upload_button and uploaded_file: datatype = trans.app.datatypes_registry.get_datatype_by_extension( params.upload_type ) if datatype is None: - message = 'An unknown filetype was selected. This should not be possble, please report the error.' + message = 'An unknown file type was selected. This should not be possible, please report the error.' status = 'error' else: try: + # Initialize the tool object meta = datatype.verify( uploaded_file ) meta.user = trans.user meta.guid = trans.app.security.get_new_guid() obj = datatype.create_model_object( meta ) trans.sa_session.add( obj ) + if isinstance( obj, trans.app.model.Tool ): + if category_ids: + for category_id in category_ids: + category = trans.app.model.Category.get( trans.security.decode_id( category_id ) ) + # Initialize the tool category + tca = trans.app.model.ToolCategoryAssociation( obj, category ) + trans.sa_session.add( tca ) + # Initialize the tool event + event = trans.app.model.Event( trans.app.model.Tool.states.NEW ) + tea = trans.app.model.ToolEventAssociation( obj, event ) + trans.sa_session.add_all( ( event, tea ) ) trans.sa_session.flush() try: os.link( uploaded_file.name, obj.file_name ) except OSError: shutil.copy( uploaded_file.name, obj.file_name ) - return trans.response.send_redirect( web.url_for( controller='tool_browser', + # We're setting cntrller to 'tool_browser' since that is the only controller from which we can upload + return trans.response.send_redirect( web.url_for( controller='common', action='edit_tool', + cntrller='tool_browser', id=trans.app.security.encode_id( obj.id ), message='Uploaded %s' % meta.message, status='done' ) ) @@ -62,8 +77,11 @@ status = 'error' uploaded_file.close() selected_upload_type = params.get( 'type', 'tool' ) + selected_categories = [ trans.security.decode_id( id ) for id in category_ids ] return trans.fill_template( '/webapps/community/upload/upload.mako', message=message, status=status, selected_upload_type=selected_upload_type, - upload_types=trans.app.datatypes_registry.get_datatypes_for_select_list() ) + upload_types=trans.app.datatypes_registry.get_datatypes_for_select_list(), + selected_categories=selected_categories, + categories=get_categories( trans ) ) diff -r 054527415fac -r 83102f27fd1d lib/galaxy/webapps/community/model/__init__.py --- a/lib/galaxy/webapps/community/model/__init__.py Mon Apr 26 15:40:52 2010 -0400 +++ b/lib/galaxy/webapps/community/model/__init__.py Mon Apr 26 17:02:37 2010 -0400 @@ -87,6 +87,12 @@ class Tool( object ): file_path = '/tmp' + states = Bunch( NEW = 'new', + ERROR = 'error', + DELETED = 'deleted', + WAITING = 'waiting for approval', + APPROVED = 'approved', + REJECTED = 'rejected' ) def __init__( self, guid=None, tool_id=None, name=None, description=None, user_description=None, category=None, version=None, user_id=None, external_filename=None ): self.guid = guid self.tool_id = tool_id @@ -124,15 +130,27 @@ self.description = datatype_bunch.description self.version = datatype_bunch.version self.user_id = datatype_bunch.user.id - def set_categories( self, trans, categories, delete_existing_assocs=True ): - if delete_existing_assocs: - for a in self.categories: - trans.sa_session.delete( a ) - trans.sa_session.flush() - for category in categories: - if not isinstance( category, Category ): - category = trans.sa_session.query( Category ).get( int( category ) ) - self.categories.append( ToolCategoryAssociation( self, category ) ) + def state( self ): + if self.events: + return self.events[0].event.state + return None + def last_comment( self ): + if self.events: + if self.events[0].comment: + return self.events[0].comment + else: + return '' + return 'No comment' + def is_new( self ): + return self.state() == self.states.NEW + def is_error( self ): + return self.state() == self.states.ERROR + def is_deleted( self ): + return self.state() == self.states.DELETED + def is_approved( self ): + return self.state() == self.states.APPROVED + def is_rejected( self ): + return self.state() == self.states.REJECTED @property def extension( self ): # if instantiated via a query, this unmapped property won't exist @@ -162,6 +180,27 @@ def mimetype( self ): return mimetypes.guess_type( self.download_file_name )[0] +class Event( object ): + def __init__( self, state=None, comment='' ): + self.state = state + self.comment = comment + +class ToolEventAssociation( object ): + def __init__( self, tool=None, event=None ): + self.tool = tool + self.event = event + +class Category( object ): + def __init__( self, name=None, description=None, deleted=False ): + self.name = name + self.description = description + self.deleted = deleted + +class ToolCategoryAssociation( object ): + def __init__( self, tool=None, category=None ): + self.tool = tool + self.category = category + class Tag ( object ): def __init__( self, id=None, type=None, parent_id=None, name=None ): self.id = id @@ -170,12 +209,6 @@ self.name = name def __str__ ( self ): return "Tag(id=%s, type=%i, parent_id=%s, name=%s)" % ( self.id, self.type, self.parent_id, self.name ) - -class Category( object ): - def __init__( self, name=None, description=None, deleted=False ): - self.name = name - self.description = description - self.deleted = deleted class ItemTagAssociation ( object ): def __init__( self, id=None, user=None, item_id=None, tag_id=None, user_tname=None, value=None ): @@ -193,11 +226,6 @@ class ToolAnnotationAssociation( object ): pass -class ToolCategoryAssociation( object ): - def __init__( self, tool=None, category=None ): - self.tool = tool - self.category = category - ## ---- Utility methods ------------------------------------------------------- def directory_hash_id( id ): diff -r 054527415fac -r 83102f27fd1d lib/galaxy/webapps/community/model/mapping.py --- a/lib/galaxy/webapps/community/model/mapping.py Mon Apr 26 15:40:52 2010 -0400 +++ b/lib/galaxy/webapps/community/model/mapping.py Mon Apr 26 17:02:37 2010 -0400 @@ -112,7 +112,7 @@ Column( "version", TrimmedString( 255 ) ), Column( "user_id", Integer, ForeignKey( "galaxy_user.id" ), index=True ), Column( "external_filename" , TEXT ), - Column( "deleted", Boolean, default=False ) ) + Column( "deleted", Boolean, index=True, default=False ) ) Category.table = Table( "category", metadata, Column( "id", Integer, primary_key=True ), @@ -127,6 +127,18 @@ Column( "tool_id", Integer, ForeignKey( "tool.id" ), index=True ), Column( "category_id", Integer, ForeignKey( "category.id" ), index=True ) ) +Event.table = Table( 'event', metadata, + Column( "id", Integer, primary_key=True ), + Column( "create_time", DateTime, default=now ), + Column( "update_time", DateTime, default=now, onupdate=now ), + Column( "state", TrimmedString( 255 ), index=True ), + Column( "comment", TEXT ) ) + +ToolEventAssociation.table = Table( "tool_event_association", metadata, + Column( "id", Integer, primary_key=True ), + Column( "tool_id", Integer, ForeignKey( "tool.id" ), index=True ), + Column( "event_id", Integer, ForeignKey( "event.id" ), index=True ) ) + Tag.table = Table( "tag", metadata, Column( "id", Integer, primary_key=True ), Column( "type", Integer ), @@ -202,7 +214,17 @@ assign_mapper( context, Tool, Tool.table, properties = dict( categories=relation( ToolCategoryAssociation ), + events=relation( ToolEventAssociation ), user=relation( User.mapper ) + ) ) + +assign_mapper( context, Event, Event.table, + properties=None ) + +assign_mapper( context, ToolEventAssociation, ToolEventAssociation.table, + properties=dict( + tool=relation( Tool ), + event=relation( Event ) ) ) @@ -211,8 +233,8 @@ assign_mapper( context, ToolCategoryAssociation, ToolCategoryAssociation.table, properties=dict( - category=relation(Category), - tool=relation(Tool) + category=relation( Category ), + tool=relation( Tool ) ) ) diff -r 054527415fac -r 83102f27fd1d lib/galaxy/webapps/community/model/migrate/versions/0001_initial_tables.py --- a/lib/galaxy/webapps/community/model/migrate/versions/0001_initial_tables.py Mon Apr 26 15:40:52 2010 -0400 +++ b/lib/galaxy/webapps/community/model/migrate/versions/0001_initial_tables.py Mon Apr 26 17:02:37 2010 -0400 @@ -89,7 +89,19 @@ Column( "version", TrimmedString( 255 ) ), Column( "user_id", Integer, ForeignKey( "galaxy_user.id" ), index=True ), Column( "external_filename" , TEXT ), - Column( "deleted", Boolean, default=False ) ) + Column( "deleted", Boolean, index=True, default=False ) ) + +Event_table = Table( 'event', metadata, + Column( "id", Integer, primary_key=True ), + Column( "create_time", DateTime, default=now ), + Column( "update_time", DateTime, default=now, onupdate=now ), + Column( "state", TrimmedString( 255 ), index=True ), + Column( "comment", TEXT ) ) + +ToolEventAssociation_table = Table( "tool_event_association", metadata, + Column( "id", Integer, primary_key=True ), + Column( "tool_id", Integer, ForeignKey( "tool.id" ), index=True ), + Column( "event_id", Integer, ForeignKey( "event.id" ), index=True ) ) Category_table = Table( "category", metadata, Column( "id", Integer, primary_key=True ), diff -r 054527415fac -r 83102f27fd1d templates/webapps/community/admin/category/category_create.mako --- a/templates/webapps/community/admin/category/category_create.mako Mon Apr 26 15:40:52 2010 -0400 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,35 +0,0 @@ -<%inherit file="/base.mako"/> -<%namespace file="/message.mako" import="render_msg" /> - -<%def name="javascripts()"> - ${parent.javascripts()} - <script type="text/javascript"> - $(function(){ - $("input:text:first").focus(); - }) - </script> -</%def> - -%if message: - ${render_msg( message, status )} -%endif - -<div class="toolForm"> - <div class="toolFormTitle">Create Role</div> - <div class="toolFormBody"> - <form name="create_category_form" id="create_category_form" action="${h.url_for( action='create_category' )}" method="post" > - <div class="form-row"> - <input name="webapp" type="hidden" value="${webapp}" size=40"/> - <label>Name:</label> - <input name="name" type="textfield" value="" size=40"/> - </div> - <div class="form-row"> - <label>Description:</label> - <input name="description" type="textfield" value="" size=40"/> - </div> - <div class="form-row"> - <input type="submit" name="create_category_button" value="Save"/> - </div> - </form> - </div> -</div> diff -r 054527415fac -r 83102f27fd1d templates/webapps/community/admin/category/category_rename.mako --- a/templates/webapps/community/admin/category/category_rename.mako Mon Apr 26 15:40:52 2010 -0400 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,44 +0,0 @@ -<%inherit file="/base.mako"/> -<%namespace file="/message.mako" import="render_msg" /> - -%if message: - ${render_msg( message, status )} -%endif - -<div class="toolForm"> - <div class="toolFormTitle">Change category name and description</div> - <div class="toolFormBody"> - <form name="library" action="${h.url_for( controller='admin', action='rename_category' )}" method="post" > - <div class="form-row"> - <input name="webapp" type="hidden" value="${webapp}" size=40"/> - <label>Name:</label> - <div style="float: left; width: 250px; margin-right: 10px;"> - <input type="text" name="name" value="${category.name}" size="40"/> - </div> - <div style="clear: both"></div> - </div> - <div class="form-row"> - <label>Description:</label> - <div style="float: left; width: 250px; margin-right: 10px;"> - <input name="description" type="textfield" value="${category.description}" size=40"/> - </div> - <div style="clear: both"></div> - </div> - <div class="form-row"> - <div style="float: left; width: 250px; margin-right: 10px;"> - <input type="hidden" name="rename" value="submitted"/> - </div> - <div style="clear: both"></div> - </div> - <div class="form-row"> - <div style="float: left; width: 250px; margin-right: 10px;"> - <input type="hidden" name="id" value="${trans.security.encode_id( category.id )}"/> - </div> - <div style="clear: both"></div> - </div> - <div class="form-row"> - <input type="submit" name="rename_category_button" value="Save"/> - </div> - </form> - </div> -</div> diff -r 054527415fac -r 83102f27fd1d templates/webapps/community/admin/category/grid.mako --- a/templates/webapps/community/admin/category/grid.mako Mon Apr 26 15:40:52 2010 -0400 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1 +0,0 @@ -<%inherit file="/grid_base.mako"/> diff -r 054527415fac -r 83102f27fd1d templates/webapps/community/admin/category/role.mako --- a/templates/webapps/community/admin/category/role.mako Mon Apr 26 15:40:52 2010 -0400 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,118 +0,0 @@ -<%inherit file="/base.mako"/> -<%namespace file="/message.mako" import="render_msg" /> - -<%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() { - $('#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#associate_role_user_group').submit(function() { - $('#in_users option').each(function(i) { - $(this).attr("selected", "selected"); - }); - $('#in_groups option').each(function(i) { - $(this).attr("selected", "selected"); - }); - }); -}); -</script> - -%if message: - ${render_msg( message, status )} -%endif - -<div class="toolForm"> - <div class="toolFormTitle">Role '${role.name}'</div> - <div class="toolFormBody"> - <form name="associate_role_user_group" id="associate_role_user_group" action="${h.url_for( action='manage_users_and_groups_for_role', id=trans.security.encode_id( role.id ) )}" method="post" > - <input name="webapp" type="hidden" value="${webapp}" size=40"/> - <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> - <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> - </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> - <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> - </div> - <div class="form-row"> - <input type="submit" name="role_members_edit_button" value="Save"/> - </div> - </form> - </div> -</div> -<br clear="left"/> -<br/> -%if len( library_dataset_actions ) > 0: - <h3>Data library datasets associated with role '${role.name}'</h3> - <table class="manage-table colored" border="0" cellspacing="0" cellpadding="0" width="100%"> - <tr> - <td> - <ul> - %for ctr, library, in enumerate( library_dataset_actions.keys() ): - <li> - <img src="${h.url_for( '/static/images/silk/book_open.png' )}" class="rowIcon"/> - ${library.name} - <ul> - %for folder_path, permissions in library_dataset_actions[ library ].items(): - <li> - <img src="/static/images/silk/folder_page.png" class="rowIcon"/> - ${folder_path} - <ul> - % for permission in permissions: - <ul> - <li>${permission}</li> - </ul> - %endfor - </ul> - </li> - %endfor - </ul> - </li> - %endfor - </ul> - </td> - </tr> - </table> -%endif diff -r 054527415fac -r 83102f27fd1d templates/webapps/community/admin/index.mako --- a/templates/webapps/community/admin/index.mako Mon Apr 26 15:40:52 2010 -0400 +++ b/templates/webapps/community/admin/index.mako Mon Apr 26 17:02:37 2010 -0400 @@ -85,7 +85,7 @@ </div> <div class="toolSectionBody"> <div class="toolSectionBg"> - <div class="toolTitle"><a href="${h.url_for( controller='tool_browser', action='browse_tools', webapp='community' )}" target="galaxy_main">Manage tools</a></div> + <div class="toolTitle"><a href="${h.url_for( controller='admin', action='browse_tools', webapp='community' )}" target="galaxy_main">Manage tools</a></div> </div> </div> <div class="toolSectionPad"></div> diff -r 054527415fac -r 83102f27fd1d templates/webapps/community/category/add_to_category.mako --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/webapps/community/category/add_to_category.mako Mon Apr 26 17:02:37 2010 -0400 @@ -0,0 +1,24 @@ +<%inherit file="/base.mako"/> +<%namespace file="/message.mako" import="render_msg" /> + +%if message: + ${render_msg( message, status )} +%endif + +<div class="toolForm"> + <div class="toolFormTitle">Select a category</div> + <div class="toolFormBody"> + <form id="select_category" name="select_category" action="${h.url_for( controller='common', action='add_category', cntrller=cntrller, id=id, use_panels=use_panels )}" method="post" > + <div class="form-row"> + <label>Category:</label> + ${category_select_list.get_html()} + </div> + <div class="toolParamHelp" style="clear: both;"> + Multi-select list - hold the appropriate key while clicking to select multiple columns + </div> + <div class="form-row"> + <input type="submit" name="add_category_button" value="Add tool to categories"/> + </div> + </form> + </div> +</div> diff -r 054527415fac -r 83102f27fd1d templates/webapps/community/category/create_category.mako --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/webapps/community/category/create_category.mako Mon Apr 26 17:02:37 2010 -0400 @@ -0,0 +1,35 @@ +<%inherit file="/base.mako"/> +<%namespace file="/message.mako" import="render_msg" /> + +<%def name="javascripts()"> + ${parent.javascripts()} + <script type="text/javascript"> + $(function(){ + $("input:text:first").focus(); + }) + </script> +</%def> + +%if message: + ${render_msg( message, status )} +%endif + +<div class="toolForm"> + <div class="toolFormTitle">Create Role</div> + <div class="toolFormBody"> + <form name="create_category_form" id="create_category_form" action="${h.url_for( action='create_category' )}" method="post" > + <div class="form-row"> + <input name="webapp" type="hidden" value="${webapp}" size=40"/> + <label>Name:</label> + <input name="name" type="textfield" value="" size=40"/> + </div> + <div class="form-row"> + <label>Description:</label> + <input name="description" type="textfield" value="" size=40"/> + </div> + <div class="form-row"> + <input type="submit" name="create_category_button" value="Save"/> + </div> + </form> + </div> +</div> diff -r 054527415fac -r 83102f27fd1d templates/webapps/community/category/grid.mako --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/webapps/community/category/grid.mako Mon Apr 26 17:02:37 2010 -0400 @@ -0,0 +1,1 @@ +<%inherit file="/grid_base.mako"/> diff -r 054527415fac -r 83102f27fd1d templates/webapps/community/category/rename_category.mako --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/templates/webapps/community/category/rename_category.mako Mon Apr 26 17:02:37 2010 -0400 @@ -0,0 +1,44 @@ +<%inherit file="/base.mako"/> +<%namespace file="/message.mako" import="render_msg" /> + +%if message: + ${render_msg( message, status )} +%endif + +<div class="toolForm"> + <div class="toolFormTitle">Change category name and description</div> + <div class="toolFormBody"> + <form name="library" action="${h.url_for( controller='admin', action='rename_category' )}" method="post" > + <div class="form-row"> + <input name="webapp" type="hidden" value="${webapp}" size=40"/> + <label>Name:</label> + <div style="float: left; width: 250px; margin-right: 10px;"> + <input type="text" name="name" value="${category.name}" size="40"/> + </div> + <div style="clear: both"></div> + </div> + <div class="form-row"> + <label>Description:</label> + <div style="float: left; width: 250px; margin-right: 10px;"> + <input name="description" type="textfield" value="${category.description}" size=40"/> + </div> + <div style="clear: both"></div> + </div> + <div class="form-row"> + <div style="float: left; width: 250px; margin-right: 10px;"> + <input type="hidden" name="rename" value="submitted"/> + </div> + <div style="clear: both"></div> + </div> + <div class="form-row"> + <div style="float: left; width: 250px; margin-right: 10px;"> + <input type="hidden" name="id" value="${trans.security.encode_id( category.id )}"/> + </div> + <div style="clear: both"></div> + </div> + <div class="form-row"> + <input type="submit" name="rename_category_button" value="Save"/> + </div> + </form> + </div> +</div> diff -r 054527415fac -r 83102f27fd1d templates/webapps/community/tool/edit_tool.mako --- a/templates/webapps/community/tool/edit_tool.mako Mon Apr 26 15:40:52 2010 -0400 +++ b/templates/webapps/community/tool/edit_tool.mako Mon Apr 26 17:02:37 2010 -0400 @@ -17,57 +17,56 @@ ${render_msg( message, status )} %endif -<form id="tool_edit_form" name="tool_edit_form" action="${h.url_for( controller='tool_browser', action='edit_tool' )}" enctype="multipart/form-data" method="post"> -<input type="hidden" name="id" value="${trans.app.security.encode_id( tool.id )}"/> -<div class="toolForm"> - <div class="toolFormTitle">Edit Tool</div> - <div class="toolFormBody"> - <div class="form-row"> - <label>Categories:</label> - <div class="form-row-input"> - <select name="category" multiple size=5 style="min-width: 250px;"> - %for category in categories: - %if category.id in [ tool_category.id for tool_category in tool.categories ]: - <option value="${category.id}" selected>${category.name}</option> - %else: - <option value="${category.id}">${category.name}</option> - %endif - %endfor - </select> +<form id="tool_edit_form" name="tool_edit_form" action="${h.url_for( controller='common', action='edit_tool' )}" enctype="multipart/form-data" method="post"> + <input type="hidden" name="id" value="${trans.app.security.encode_id( tool.id )}"/> + <input type="hidden" name="cntrller" value="${cntrller}"/> + <div class="toolForm"> + <div class="toolFormTitle">Edit Tool</div> + <div class="toolFormBody"> + <div class="form-row"> + <label>Categories:</label> + <div class="form-row-input"> + <select name="category_id" multiple size=5 style="min-width: 250px;"> + %for category in categories: + %if category.id in [ tool_category.id for tool_category in tool.categories ]: + <option value="${category.id}" selected>${category.name}</option> + %else: + <option value="${category.id}">${category.name}</option> + %endif + %endfor + </select> + </div> + <div style="clear: both"></div> </div> - <div style="clear: both"></div> + <div class="form-row"> + <label>Description:</label> + <div class="form-row-input"><textarea name="description" rows="5" cols="35">${tool.user_description}</textarea></div> + <div style="clear: both"></div> + </div> + <div class="form-row"> + <input type="submit" class="primary-button" name="save_button" value="Save"> + </div> + </div> </div> - <div class="form-row"> - <label>Description:</label> - <div class="form-row-input"><textarea name="description" rows="5" cols="35">${tool.user_description}</textarea></div> - <div style="clear: both"></div> + <p/> + <div class="toolForm"> + <div class="toolFormTitle">Upload new version</div> + <div class="toolFormBody"> + <div class="form-row"> + <label>File:</label> + <div class="form-row-input"><input type="file" name="file_data"/></div> + <div style="clear: both"></div> + </div> + <div class="form-row"> + <label>URL:</label> + <div class="form-row-input"><input type="text" name="url" style="width: 100%;"/></div> + <div class="toolParamHelp" style="clear: both;"> + Instead of uploading directly from your computer, you may instruct Galaxy to download the file from a Web or FTP address. + </div> + <div style="clear: both"></div> + </div> + <div class="form-row"> + <input type="submit" class="primary-button" name="save_button" value="Save"> + </div> </div> - <div class="form-row"> - <input type="submit" class="primary-button" name="save_button" value="Save"> - </div> - </div> -</div> - -<p/> - -<div class="toolForm"> - <div class="toolFormTitle">Upload new version</div> - <div class="toolFormBody"> - <div class="form-row"> - <label>File:</label> - <div class="form-row-input"><input type="file" name="file_data"/></div> - <div style="clear: both"></div> - </div> - <div class="form-row"> - <label>URL:</label> - <div class="form-row-input"><input type="text" name="url" style="width: 100%;"/></div> - <div class="toolParamHelp" style="clear: both;"> - Instead of uploading directly from your computer, you may instruct Galaxy to download the file from a Web or FTP address. - </div> - <div style="clear: both"></div> - </div> - <div class="form-row"> - <input type="submit" class="primary-button" name="save_button" value="Save"> - </div> -</div> </form> diff -r 054527415fac -r 83102f27fd1d templates/webapps/community/upload/upload.mako --- a/templates/webapps/community/upload/upload.mako Mon Apr 26 15:40:52 2010 -0400 +++ b/templates/webapps/community/upload/upload.mako Mon Apr 26 17:02:37 2010 -0400 @@ -46,6 +46,21 @@ <div style="clear: both"></div> </div> <div class="form-row"> + <label>Category</label> + <div class="form-row-input"> + <select name="category_id"> + %for category in categories: + %if category.id in selected_categories: + <option value="${trans.security.encode_id( category.id )}" selected>${category.name}</option> + %else: + <option value="${trans.security.encode_id( category.id )}">${category.name}</option> + %endif + %endfor + </select> + </div> + <div style="clear: both"></div> + </div> + <div class="form-row"> <label>URL:</label> <div class="form-row-input"><input type="text" name="url" style="width: 100%;"/></div> <div class="toolParamHelp" style="clear: both;">
participants (1)
-
Nate Coraor