Commits

Greg Von Kuster committed 84187b3

Enhance User preferences within a Galaxy tool shed to enable users to elect to receive email when content is first uploaded to a new repository. Also add a new grid interface to enable users to easily manage registering with any tool shed respositories to receive email when changes are made to the selected repositories.

Comments (0)

Files changed (11)

lib/galaxy/tools/parameters/basic.py

         """Factory method to create parameter of correct type"""
         param_name = param.get( "name" )
         if not param_name:
-            raise ValueError( "Tool parameters require a 'name'" )
+            raise ValueError( "Tool parameter '%s' requires a 'name'" % (param_name ) )
         param_type = param.get("type")
         if not param_type:
             raise ValueError( "Tool parameter '%s' requires a 'type'" % ( param_name ) )

lib/galaxy/webapps/community/controllers/admin.py

                 kwd[ 'f-Category.name' ] = category.name
             elif operation == "receive email alerts":
                 if kwd[ 'id' ]:
+                    kwd[ 'caller' ] = 'browse_repositories'
                     return trans.response.send_redirect( web.url_for( controller='repository',
                                                                       action='set_email_alerts',
                                                                       **kwd ) )

lib/galaxy/webapps/community/controllers/common.py

 
 log = logging.getLogger( __name__ )
 
+new_repo_email_alert_template = """
+GALAXY TOOL SHED NEW REPOSITORY ALERT
+-----------------------------------------------------------------------------
+You received this alert because you registered to receive email when
+new repositories were created in the Galaxy tool shed named "${host}".
+-----------------------------------------------------------------------------
+
+Repository name:       ${repository_name}
+Date content uploaded: ${display_date}
+Uploaded by:           ${username}
+
+Revision: ${revision}
+Change description:
+${description}
+
+${content_alert_str}
+
+-----------------------------------------------------------------------------
+This change alert was sent from the Galaxy tool shed hosted on the server
+"${host}"
+"""
+
 email_alert_template = """
 GALAXY TOOL SHED REPOSITORY UPDATE ALERT
 -----------------------------------------------------------------------------
             except Exception, e:
                 invalid_files.append( ( name, str( e ) ) )
     return metadata_dict, invalid_files
-def set_repository_metadata( trans, id, changeset_revision, **kwd ):
+def set_repository_metadata( trans, id, changeset_revision, content_alert_str='', **kwd ):
     """Set repository metadata"""
     message = ''
     status = 'done'
                     repository_metadata = trans.model.RepositoryMetadata( repository.id, changeset_revision, metadata_dict )
                     trans.sa_session.add( repository_metadata )
                     trans.sa_session.flush()
+                    # If this is the first record stored for this repository, see if we need to send any email alerts.
+                    if len( repository.downloadable_revisions ) == 1:
+                        handle_email_alerts( trans, repository, content_alert_str='', new_repo_alert=True, admin_only=False )
                 else:
                     # Update the last saved repository_metadata table row.
                     repository_metadata = get_latest_repository_metadata( trans, id )
 def get_user( trans, id ):
     """Get a user from the database by id"""
     return trans.sa_session.query( trans.model.User ).get( trans.security.decode_id( id ) )
-def handle_email_alerts( trans, repository, content_alert_str='' ):
+def handle_email_alerts( trans, repository, content_alert_str='', new_repo_alert=False, admin_only=False ):
+    # There are 2 complementary features that enable a tool shed user to receive email notification:
+    # 1. Within User Preferences, they can elect to receive email when the first (or first valid)
+    #    change set is produced for a new repository.
+    # 2. When viewing or managing a repository, they can check the box labeled "Receive email alerts"
+    #    which caused them to receive email alerts when updates to the repository occur.  This same feature
+    #    is available on a per-repository basis on the repository grid within the tool shed.
+    #
+    # There are currently 4 scenarios for sending email notification when a change is made to a repository:
+    # 1. An admin user elects to receive email when the first change set is produced for a new repository
+    #    from User Preferences.  The change set does not have to include any valid content.  This allows for
+    #    the capture of inappropriate content being uploaded to new repositories.
+    # 2. A regular user elects to receive email when the first valid change set is produced for a new repository
+    #    from User Preferences.  This differs from 1 above in that the user will not receive email until a
+    #    change set tha tincludes valid content is produced.
+    # 3. An admin user checks the "Receive email alerts" check box on the manage repository page.  Since the
+    #    user is an admin user, the email will include information about both HTML and image content that was
+    #    included in the change set.
+    # 4. A regular user checks the "Receive email alerts" check box on the manage repository page.  Since the
+    #    user is not an admin user, the email will not include any information about both HTML and image content
+    #    that was included in the change set.
     repo_dir = repository.repo_path
     repo = hg.repository( get_configured_ui(), repo_dir )
     smtp_server = trans.app.config.smtp_server
-    if smtp_server and repository.email_alerts:
+    if smtp_server and ( new_repo_alert or repository.email_alerts ):
         # Send email alert to users that want them.
         if trans.app.config.email_from is not None:
             email_from = trans.app.config.email_from
             username = ctx.user()
         # We'll use 2 template bodies because we only want to send content
         # alerts to tool shed admin users.
-        admin_body = string.Template( email_alert_template ) \
-            .safe_substitute( host=trans.request.host,
-                              repository_name=repository.name,
-                              revision='%s:%s' %( str( ctx.rev() ), ctx ),
-                              display_date=display_date,
-                              description=ctx.description(),
-                              username=username,
-                              content_alert_str=content_alert_str )
-        body = string.Template( email_alert_template ) \
-            .safe_substitute( host=trans.request.host,
-                              repository_name=repository.name,
-                              revision='%s:%s' %( str( ctx.rev() ), ctx ),
-                              display_date=display_date,
-                              description=ctx.description(),
-                              username=username,
-                              content_alert_str='' )
+        if new_repo_alert:
+            template = new_repo_email_alert_template
+        else:
+            template = email_alert_template
+        admin_body = string.Template( template ).safe_substitute( host=trans.request.host,
+                                                                  repository_name=repository.name,
+                                                                  revision='%s:%s' %( str( ctx.rev() ), ctx ),
+                                                                  display_date=display_date,
+                                                                  description=ctx.description(),
+                                                                  username=username,
+                                                                  content_alert_str=content_alert_str )
+        body = string.Template( template ).safe_substitute( host=trans.request.host,
+                                                            repository_name=repository.name,
+                                                            revision='%s:%s' %( str( ctx.rev() ), ctx ),
+                                                            display_date=display_date,
+                                                            description=ctx.description(),
+                                                            username=username,
+                                                            content_alert_str='' )
         admin_users = trans.app.config.get( "admin_users", "" ).split( "," )
         frm = email_from
-        subject = "Galaxy tool shed repository update alert"
-        email_alerts = from_json_string( repository.email_alerts )
+        if new_repo_alert:
+            subject = "New Galaxy tool shed repository alert"
+            email_alerts = []
+            for user in trans.sa_session.query( trans.model.User ) \
+                                        .filter( and_( trans.model.User.table.c.deleted == False,
+                                                       trans.model.User.table.c.new_repo_alert == True ) ):
+                if admin_only:
+                    if user.email in admin_users:
+                        email_alerts.append( user.email )
+                else:
+                    email_alerts.append( user.email )
+        else:
+            subject = "Galaxy tool shed repository update alert"
+            email_alerts = from_json_string( repository.email_alerts )
         for email in email_alerts:
             to = email.strip()
             # Send it

lib/galaxy/webapps/community/controllers/repository.py

                                .outerjoin( model.RepositoryCategoryAssociation.table ) \
                                .outerjoin( model.Category.table )
 
+class EmailAlertsRepositoryListGrid( RepositoryListGrid ):
+    columns = [
+        RepositoryListGrid.NameColumn( "Name",
+                                       key="name",
+                                       link=( lambda item: dict( operation="view_or_manage_repository",
+                                                                 id=item.id,
+                                                                 webapp="community" ) ),
+                                       attach_popup=False ),
+        RepositoryListGrid.DescriptionColumn( "Synopsis",
+                                              key="description",
+                                              attach_popup=False ),
+        RepositoryListGrid.UserColumn( "Owner",
+                                       model_class=model.User,
+                                       link=( lambda item: dict( operation="repositories_by_user", id=item.id, webapp="community" ) ),
+                                       attach_popup=False,
+                                       key="User.username" ),
+        RepositoryListGrid.EmailAlertsColumn( "Alert", attach_popup=False ),
+        # Columns that are valid for filtering but are not visible.
+        grids.DeletedColumn( "Deleted",
+                             key="deleted",
+                             visible=False,
+                             filterable="advanced" )
+    ]
+    operations = [ grids.GridOperation( "Receive email alerts",
+                                        allow_multiple=True,
+                                        condition=( lambda item: not item.deleted ),
+                                        async_compatible=False ) ]
+    global_actions = [
+            grids.GridAction( "User preferences", dict( controller='user', action='index', cntrller='repository', webapp='community' ) )
+        ]
+
 class ValidRepositoryListGrid( RepositoryListGrid ):
     class RevisionColumn( grids.GridColumn ):
         def __init__( self, col_name ):
     matched_repository_list_grid = MatchedRepositoryListGrid()
     valid_repository_list_grid = ValidRepositoryListGrid()
     repository_list_grid = RepositoryListGrid()
+    email_alerts_repository_list_grid = EmailAlertsRepositoryListGrid()
     category_list_grid = CategoryListGrid()
 
     @web.expose
             elif operation == "receive email alerts":
                 if trans.user:
                     if kwd[ 'id' ]:
+                        kwd[ 'caller' ] = 'browse_repositories'
                         return trans.response.send_redirect( web.url_for( controller='repository',
                                                                           action='set_email_alerts',
                                                                           **kwd ) )
     @web.require_login( "set email alerts" )
     def set_email_alerts( self, trans, **kwd ):
         # Set email alerts for selected repositories
-        params = util.Params( kwd )
+        # This method is called from multiple grids, so
+        # the caller must be passed.
+        caller = kwd[ 'caller' ]
         user = trans.user
         if user:
             repository_ids = util.listify( kwd.get( 'id', '' ) )
             kwd[ 'status' ] = 'done'
         del kwd[ 'operation' ]
         return trans.response.send_redirect( web.url_for( controller='repository',
-                                                          action='browse_repositories',
+                                                          action=caller,
                                                           **kwd ) )
     @web.expose
+    @web.require_login( "manage email alerts" )
+    def manage_email_alerts( self, trans, **kwd ):
+        params = util.Params( kwd )
+        message = util.restore_text( params.get( 'message', ''  ) )
+        status = params.get( 'status', 'done' )
+        new_repo_alert = params.get( 'new_repo_alert', '' )
+        new_repo_alert_checked = CheckboxField.is_checked( new_repo_alert )
+        user = trans.user
+        if params.get( 'new_repo_alert_button', False ):
+            user.new_repo_alert = new_repo_alert_checked
+            trans.sa_session.add( user )
+            trans.sa_session.flush()
+            if new_repo_alert_checked:
+                message = 'You will receive email alerts for all new valid tool shed repositories.'
+            else:
+                message = 'You will not receive any email alerts for new valid tool shed repositories.'
+        checked = new_repo_alert_checked or ( user and user.new_repo_alert )
+        new_repo_alert_check_box = CheckboxField( 'new_repo_alert', checked=checked )
+        email_alert_repositories = []
+        for repository in trans.sa_session.query( trans.model.Repository ) \
+                                          .filter( and_( trans.model.Repository.table.c.deleted == False,
+                                                         trans.model.Repository.table.c.email_alerts != None ) ) \
+                                          .order_by( trans.model.Repository.table.c.name ):
+            if user.email in repository.email_alerts:
+                email_alert_repositories.append( repository )
+        return trans.fill_template( "/webapps/community/user/manage_email_alerts.mako",
+                                    webapp='community',
+                                    new_repo_alert_check_box=new_repo_alert_check_box,
+                                    email_alert_repositories=email_alert_repositories,
+                                    message=message,
+                                    status=status )
+    @web.expose
+    @web.require_login( "manage email alerts" )
+    def multi_select_email_alerts( self, trans, **kwd ):
+        params = util.Params( kwd )
+        message = util.restore_text( params.get( 'message', ''  ) )
+        status = params.get( 'status', 'done' )
+        if 'webapp' not in kwd:
+            kwd[ 'webapp' ] = 'community'
+        if 'operation' in kwd:
+            operation = kwd['operation'].lower()
+            if operation == "receive email alerts":
+                if trans.user:
+                    if kwd[ 'id' ]:
+                        kwd[ 'caller' ] = 'multi_select_email_alerts'
+                        return trans.response.send_redirect( web.url_for( controller='repository',
+                                                                          action='set_email_alerts',
+                                                                          **kwd ) )
+                else:
+                    kwd[ 'message' ] = 'You must be logged in to set email alerts.'
+                    kwd[ 'status' ] = 'error'
+                    del kwd[ 'operation' ]
+        return self.email_alerts_repository_list_grid( trans, **kwd )
+    @web.expose
     @web.require_login( "set repository metadata" )
     def set_metadata( self, trans, id, ctx_str, **kwd ):
         malicious = kwd.get( 'malicious', '' )

lib/galaxy/webapps/community/controllers/upload.py

         tip = repository.tip
         file_data = params.get( 'file_data', '' )
         url = params.get( 'url', '' )
+        # Part of the upload process is sending email notification to those that have registered to
+        # receive them.  One scenario occurs when the first change set is produced for the repository.
+        # See the handle_email_alerts() method for the definition of the scenarios.
+        new_repo_alert = repository.is_new
         if params.get( 'upload_button', False ):
             current_working_dir = os.getcwd()
             if file_data == '' and url == '':
                         tar = None
                         istar = False
                 if istar:
-                    ok, message, files_to_remove = self.upload_tar( trans,
-                                                                    repository,
-                                                                    tar,
-                                                                    uploaded_file,
-                                                                    upload_point,
-                                                                    remove_repo_files_not_in_tar,
-                                                                    commit_message )
+                    ok, message, files_to_remove, content_alert_str = self.upload_tar( trans,
+                                                                                       repository,
+                                                                                       tar,
+                                                                                       uploaded_file,
+                                                                                       upload_point,
+                                                                                       remove_repo_files_not_in_tar,
+                                                                                       commit_message,
+                                                                                       new_repo_alert )
                 else:
                     if ( isgzip or isbz2 ) and uncompress_file:
                         uploaded_file_filename = self.uncompress( repository, uploaded_file_name, uploaded_file_filename, isgzip, isbz2 )
                         # Handle the special case where a xxx.loc.sample file is
                         # being uploaded by copying it to ~/tool-data/xxx.loc.
                         copy_sample_loc_file( trans, full_path )
-                    handle_email_alerts( trans, repository, content_alert_str=content_alert_str )
+                    # See if the content of the change set was valid.
+                    admin_only = len( repository.downloadable_revisions ) != 1
+                    handle_email_alerts( trans, repository, content_alert_str=content_alert_str, new_repo_alert=new_repo_alert, admin_only=admin_only )
                 if ok:
                     # Update the repository files for browsing.
                     update_for_browsing( trans, repository, current_working_dir, commit_message=commit_message )
                     else:
                         message = 'No changes to repository.'      
                     # Set metadata on the repository tip
-                    error_message, status = set_repository_metadata( trans, repository_id, repository.tip, **kwd )
+                    error_message, status = set_repository_metadata( trans, repository_id, repository.tip, content_alert_str=content_alert_str, **kwd )
                     if error_message:
                         message = '%s<br/>%s' % ( message, error_message )
                         return trans.response.send_redirect( web.url_for( controller='repository',
                                     remove_repo_files_not_in_tar=remove_repo_files_not_in_tar,
                                     message=message,
                                     status=status )
-    def upload_tar( self, trans, repository, tar, uploaded_file, upload_point, remove_repo_files_not_in_tar, commit_message ):
+    def upload_tar( self, trans, repository, tar, uploaded_file, upload_point, remove_repo_files_not_in_tar, commit_message, new_repo_alert ):
         # Upload a tar archive of files.
         repo_dir = repository.repo_path
         repo = hg.repository( get_configured_ui(), repo_dir )
         files_to_remove = []
+        content_alert_str = ''
         ok, message = self.__check_archive( tar )
         if not ok:
             tar.close()
             uploaded_file.close()
-            return ok, message, files_to_remove
+            return ok, message, files_to_remove, content_alert_str
         else:
             if upload_point is not None:
                 full_path = os.path.abspath( os.path.join( repo_dir, upload_point ) )
             tar.extractall( path=full_path )
             tar.close()
             uploaded_file.close()
-            content_alert_str = ''
             if remove_repo_files_not_in_tar and not repository.is_new:
                 # We have a repository that is not new (it contains files), so discover
                 # those files that are in the repository, but not in the uploaded archive.
                     # appending them to the shed's tool_data_table_conf.xml file on disk.
                     error, message = handle_sample_tool_data_table_conf_file( trans, filename_in_archive )
                     if error:
-                        return False, message, files_to_remove
+                        return False, message, files_to_remove, content_alert_str
                 if filename_in_archive.endswith( '.loc.sample' ):
                     # Handle the special case where a xxx.loc.sample file is
                     # being uploaded by copying it to ~/tool-data/xxx.loc.
                 # exception.  If this happens, we'll try the following.
                 repo.dirstate.write()
                 repo.commit( user=trans.user.username, text=commit_message )
-            handle_email_alerts( trans, repository, content_alert_str )
-            return True, '', files_to_remove
+            # See if the content of the change set was valid.
+            admin_only = len( repository.downloadable_revisions ) != 1
+            handle_email_alerts( trans, repository, content_alert_str=content_alert_str, new_repo_alert=new_repo_alert, admin_only=admin_only )
+            return True, '', files_to_remove, content_alert_str
     def uncompress( self, repository, uploaded_file_name, uploaded_file_filename, isgzip, isbz2 ):
         if isgzip:
             self.__handle_gzip( repository, uploaded_file_name )

lib/galaxy/webapps/community/model/__init__.py

         self.deleted = False
         self.purged = False
         self.username = None
+        self.new_repo_alert = False
     def set_password_cleartext( self, cleartext ):
         """Set 'self.password' to the digest of 'cleartext'."""
         self.password = new_secure_hash( text_type=cleartext )

lib/galaxy/webapps/community/model/mapping.py

     Column( "username", String( 255 ), index=True ),
     Column( "password", TrimmedString( 40 ), nullable=False ),
     Column( "external", Boolean, default=False ),
+    Column( "new_repo_alert", Boolean, default=False ),
     Column( "deleted", Boolean, index=True, default=False ),
     Column( "purged", Boolean, index=True, default=False ) )
 

lib/galaxy/webapps/community/model/migrate/versions/0010_add_new_repo_alert_column.py

+"""
+Migration script to add the new_repo_alert column to the galaxy_user table.
+"""
+
+from sqlalchemy import *
+from sqlalchemy.orm import *
+from migrate import *
+from migrate.changeset import *
+
+import sys, logging
+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( migrate_engine )
+db_session = scoped_session( sessionmaker( bind=migrate_engine, autoflush=False, autocommit=True ) )
+
+def upgrade():
+    print __doc__
+    metadata.reflect()
+    # Create and initialize imported column in job table.
+    User_table = Table( "galaxy_user", metadata, autoload=True )
+    c = Column( "new_repo_alert", Boolean, default=False, index=True )
+    try:
+        # Create
+        c.create( User_table )
+        assert c is User_table.c.new_repo_alert
+        # Initialize.
+        if migrate_engine.name == 'mysql' or migrate_engine.name == 'sqlite': 
+            default_false = "0"
+        elif migrate_engine.name == 'postgres':
+            default_false = "false"
+        db_session.execute( "UPDATE galaxy_user SET new_repo_alert=%s" % default_false )
+    except Exception, e:
+        print "Adding new_repo_alert column to the galaxy_user table failed: %s" % str( e )
+        log.debug( "Adding new_repo_alert column to the galaxy_user table failed: %s" % str( e ) )
+    
+def downgrade():
+    metadata.reflect()
+    # Drop new_repo_alert column from galaxy_user table.
+    User_table = Table( "galaxy_user", metadata, autoload=True )
+    try:
+        User_table.c.new_repo_alert.drop()
+    except Exception, e:
+        print "Dropping column new_repo_alert from the galaxy_user table failed: %s" % str( e )
+        log.debug( "Dropping column new_repo_alert from the galaxy_user table failed: %s" % str( e ) )

templates/user/index.mako

                 <li><a href="${h.url_for( controller='user', action='api_keys', cntrller=cntrller )}">${_('Manage your API keys')}</a></li>
             %endif
             %if trans.app.config.enable_openid:
-                <li><a href="${h.url_for( controller='user', action='openid_manage', cntrller=cntrller )}">${ ('Manage OpenIDs')}</a> linked to your account</li>
+                <li><a href="${h.url_for( controller='user', action='openid_manage', cntrller=cntrller )}">${_('Manage OpenIDs')}</a> linked to your account</li>
             %endif
             %if trans.app.config.use_remote_user:
                 %if trans.app.config.remote_user_logout_href:
             %endif
         %else:
             <li><a href="${h.url_for( controller='user', action='manage_user_info', cntrller=cntrller,  webapp='community' )}">${_('Manage your information')}</a></li>
+            <li><a href="${h.url_for( controller='repository', action='manage_email_alerts', cntrller=cntrller,  webapp='community' )}">${_('Manage your email alerts')}</a></li>
+            <li><a href="${h.url_for( controller='user', action='logout', logout_all=True )}" target="_top">${_('Logout')}</a> ${_('of all user sessions')}</li>
         %endif
     </ul>
-    <p>
-        You are using <strong>${trans.user.get_disk_usage( nice_size=True )}</strong> of disk space in this Galaxy instance.
-        %if trans.app.config.enable_quotas:
-            Your disk quota is: <strong>${trans.app.quota_agent.get_quota( trans.user, nice_size=True )}</strong>.
-        %endif
-    </p>
+    %if webapp == 'galaxy':
+        <p>
+            You are using <strong>${trans.user.get_disk_usage( nice_size=True )}</strong> of disk space in this Galaxy instance.
+            %if trans.app.config.enable_quotas:
+                Your disk quota is: <strong>${trans.app.quota_agent.get_quota( trans.user, nice_size=True )}</strong>.
+            %endif
+        </p>
+    %endif
 %else:
     %if not message:
         <p>${n_('You are currently not logged in.')}</p>

templates/webapps/community/index.mako

                         <div class="toolTitle">
                             <a target="galaxy_main" href="${h.url_for( controller='repository', action='browse_categories', webapp='community' )}">Browse by category</a>
                         </div>
-                        <div class="toolTitle">
-                            <a target="galaxy_main" href="${h.url_for( controller='repository', action='browse_repositories', webapp='community' )}">Browse all repositories</a>
-                        </div>
                         %if trans.user:
                             <div class="toolTitle">
                                 <a target="galaxy_main" href="${h.url_for( controller='repository', action='browse_repositories', operation='my_repositories', webapp='community' )}">Browse my repositories</a>

templates/webapps/community/user/manage_email_alerts.mako

+<%inherit file="/base.mako"/>
+<%namespace file="/message.mako" import="render_msg" />
+
+<br/><br/>
+<ul class="manage-table-actions">
+    <li>
+        <a class="action-button"  href="${h.url_for( controller='repository', action='multi_select_email_alerts', cntrller='repository', webapp=webapp )}">Manage repository alerts</a>
+    </li>
+    <li>
+        <a class="action-button"  href="${h.url_for( controller='user', action='index', cntrller='repository', webapp=webapp )}">User preferences</a>
+    </li>
+</ul>
+
+%if message:
+    ${render_msg( message, status )}
+%endif
+
+<div class="toolForm">
+    <div class="toolFormTitle">Email alerts for new repositories</div>
+    <form name="new_repo_alert" id="new_repo_alert" action="${h.url_for( controller='repository', action='manage_email_alerts', webapp=webapp )}" method="post" >
+        <div class="form-row">
+            <label>New repository alert:</label>
+            ${new_repo_alert_check_box.get_html()}
+            <div class="toolParamHelp" style="clear: both;">
+                Check the box and click <b>Save</b> to receive email when the first change set is created for a new repository.
+            </div>
+        </div>
+        <div class="form-row">
+            <input type="submit" name="new_repo_alert_button" value="Save"/>
+        </div>
+    </form>
+</div>
+<p/>
+%if email_alert_repositories:
+    <div class="toolForm">
+        <div class="toolFormTitle">You are registered to receive email alerts for changes to the following repositories</div>
+        <div class="form-row">
+            <table class="grid">
+                <tr>
+                    <th>Name</th>
+                    <th>Description</th>
+                </tr>
+                %for repository in email_alert_repositories:
+                    <tr>
+                        <td>${repository.name}</td>
+                        <td>${repository.description}</td>
+                    </tr>
+                %endfor
+            </table>
+        </div>
+    </div>
+    <p/>
+%endif
+