[#7154] Introduce SiteAdminExtension

Move neighborhood stats to an extension.

Signed-off-by: Tim Van Steenburgh <tvansteenburgh@gmail.com>
diff --git a/Allura/allura/controllers/root.py b/Allura/allura/controllers/root.py
index 198493c..d43afa4 100644
--- a/Allura/allura/controllers/root.py
+++ b/Allura/allura/controllers/root.py
@@ -68,7 +68,6 @@
     auth = AuthController()
     error = ErrorController()
     nf = NewForgeController()
-    nf.admin = SiteAdminController()
     search = SearchController()
     rest = RestController()
     if config.get('trovecategories.enableediting', 'false') == 'true':
@@ -80,6 +79,7 @@
         if n and not n.url_prefix.startswith('//'):
             n.bind_controller(self)
         self.browse = ProjectBrowseController()
+        self.nf.admin = SiteAdminController()
 
         super(RootController, self).__init__()
 
diff --git a/Allura/allura/controllers/site_admin.py b/Allura/allura/controllers/site_admin.py
index e2680dd..352d35f 100644
--- a/Allura/allura/controllers/site_admin.py
+++ b/Allura/allura/controllers/site_admin.py
@@ -25,14 +25,17 @@
 import pymongo
 import bson
 import tg
+from pylons import app_globals as g
 from pylons import tmpl_context as c
 from pylons import request
 from formencode import validators, Invalid
 from webob.exc import HTTPNotFound
 
+from allura.app import SitemapEntry
 from allura.lib import helpers as h
 from allura.lib import validators as v
 from allura.lib.decorators import require_post
+from allura.lib.plugin import SiteAdminExtension
 from allura.lib.security import require_access
 from allura.lib.widgets import form_fields as ffw
 from allura import model as M
@@ -54,24 +57,40 @@
 
     def __init__(self):
         self.task_manager = TaskManagerController()
+        c.site_admin_sidebar_menu = self.sidebar_menu()
 
     def _check_security(self):
         with h.push_context(config.get('site_admin_project', 'allura'),
                             neighborhood=config.get('site_admin_project_nbhd', 'Projects')):
             require_access(c.project, 'admin')
 
+    @expose()
+    def _lookup(self, name, *remainder):
+        for ep_name in sorted(g.entry_points['site_admin'].keys()):
+            admin_extension = g.entry_points['site_admin'][ep_name]
+            controller = admin_extension().controllers.get(name)
+            if controller:
+                return controller(), remainder
+        raise HTTPNotFound, name
+
+    def sidebar_menu(self):
+        base_url = '/nf/admin/'
+        links = [
+            SitemapEntry('Home', base_url, ui_icon=g.icons['admin']),
+            SitemapEntry('API Tickets', base_url + 'api_tickets', ui_icon=g.icons['admin']),
+            SitemapEntry('Add Subscribers', base_url + 'add_subscribers', ui_icon=g.icons['admin']),
+            SitemapEntry('New Projects', base_url + 'new_projects', ui_icon=g.icons['admin']),
+            SitemapEntry('Reclone Repo', base_url + 'reclone_repo', ui_icon=g.icons['admin']),
+            SitemapEntry('Task Manager', base_url + 'task_manager?state=busy', ui_icon=g.icons['stats']),
+        ]
+        for ep_name in sorted(g.entry_points['site_admin']):
+            g.entry_points['site_admin'][ep_name]().update_sidebar_menu(links)
+        return links
+
     @expose('jinja:allura:templates/site_admin_index.html')
     @with_trailing_slash
     def index(self):
-        neighborhoods = []
-        for n in M.Neighborhood.query.find():
-            project_count = M.Project.query.find(
-                dict(neighborhood_id=n._id)).count()
-            configured_count = M.Project.query.find(
-                dict(neighborhood_id=n._id, database_configured=True)).count()
-            neighborhoods.append((n.name, project_count, configured_count))
-        neighborhoods.sort(key=lambda n: n[0])
-        return dict(neighborhoods=neighborhoods)
+        return {}
 
     @expose('jinja:allura:templates/site_admin_api_tickets.html')
     @without_trailing_slash
@@ -380,3 +399,27 @@
         except Invalid as e:
             error = str(e)
         return dict(doc=doc, error=error)
+
+
+class StatsController(object):
+    """Show neighborhood stats."""
+    @expose('jinja:allura:templates/site_admin_stats.html')
+    @with_trailing_slash
+    def index(self):
+        neighborhoods = []
+        for n in M.Neighborhood.query.find():
+            project_count = M.Project.query.find(
+                dict(neighborhood_id=n._id)).count()
+            configured_count = M.Project.query.find(
+                dict(neighborhood_id=n._id, database_configured=True)).count()
+            neighborhoods.append((n.name, project_count, configured_count))
+        neighborhoods.sort(key=lambda n: n[0])
+        return dict(neighborhoods=neighborhoods)
+
+
+class StatsSiteAdminExtension(SiteAdminExtension):
+    controllers = {'stats': StatsController}
+
+    def update_sidebar_menu(self, links):
+        links.append(SitemapEntry('Stats', '/nf/admin/stats',
+            ui_icon=g.icons['stats']))
diff --git a/Allura/allura/lib/app_globals.py b/Allura/allura/lib/app_globals.py
index d4f1b06..3ac8499 100644
--- a/Allura/allura/lib/app_globals.py
+++ b/Allura/allura/lib/app_globals.py
@@ -254,6 +254,7 @@
             stats=_cache_eps('allura.stats'),
             site_stats=_cache_eps('allura.site_stats'),
             admin=_cache_eps('allura.admin'),
+            site_admin=_cache_eps('allura.site_admin'),
             # macro eps are used solely for ensuring that external macros are
             # imported (after load, the ep itself is not used)
             macros=_cache_eps('allura.macros'),
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index 4617303..cd0ed23 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -1038,6 +1038,36 @@
         pass
 
 
+class SiteAdminExtension(object):
+    """
+    A base class for extending the site admin area in Allura.
+
+    After extending this, expose the extension by adding an entry point in your
+    setup.py::
+
+        [allura.site_admin]
+        myext = foo.bar.baz:MySiteAdminExtension
+
+    :ivar dict controllers: Mapping of str (url component) to
+        Controllers.  Can be implemented as a ``@property`` function.  The str
+        url components will be mounted at /nf/admin/STR/ and will
+        invoke the Controller.
+    """
+
+    controllers = {}
+
+    def update_sidebar_menu(self, sidebar_links):
+        """
+        Change the site admin sidebar by modifying ``sidebar_links``.
+
+        :param sidebar_links: site admin side bar links
+        :type sidebar_links: list of :class:`allura.app.SitemapEntry`
+
+        :rtype: ``None``
+        """
+        pass
+
+
 class ImportIdConverter(object):
 
     '''
diff --git a/Allura/allura/templates/site_admin.html b/Allura/allura/templates/site_admin.html
index 8e82374..d83f06e 100644
--- a/Allura/allura/templates/site_admin.html
+++ b/Allura/allura/templates/site_admin.html
@@ -16,6 +16,9 @@
        specific language governing permissions and limitations
        under the License.
 -#}
+
+{% from 'allura:templates/jinja_master/sidebar_menu.html' import sidebar_item with context %}
+
 {% set hide_left_bar=False %}
 {% if page == 'new_projects' %}
   {% set hide_left_bar=True %}
@@ -32,15 +35,9 @@
 {% block sidebar_menu %}
 <div id="sidebar">
   <div>&nbsp;</div>
-  <ul>
-    <li class="{{page=='index' and 'active' or ''}}"><a href="{{sidebar_rel}}."><b data-icon="{{g.icons['admin'].char}}" class="ico {{g.icons['admin'].css}}"></b>Home</a></li>
-    <li class="{{page=='api_tickets' and 'active' or ''}}"><a href="{{sidebar_rel}}api_tickets"><b data-icon="{{g.icons['admin'].char}}" class="ico {{g.icons['admin'].css}}"></b>API Tickets</a></li>
-    <li class="{{page=='add_subscribers' and 'active' or ''}}"><a href="{{sidebar_rel}}add_subscribers"><b data-icon="{{g.icons['admin'].char}}" class="ico {{g.icons['admin'].css}}"></b>Add Subscribers</a></li>
-    <li class="{{page=='new_projects' and 'active' or ''}}"><a href="{{sidebar_rel}}new_projects"><b data-icon="{{g.icons['admin'].char}}" class="ico {{g.icons['admin'].css}}"></b>New Projects</a></li>
-    <li class="{{page=='reclone_repo' and 'active' or ''}}"><a href="{{sidebar_rel}}reclone_repo"><b data-icon="{{g.icons['admin'].char}}" class="ico {{g.icons['admin'].css}}"></b>Reclone Repo</a></li>
-    <li class="{{page=='task_manager' and 'active' or ''}}"><a href="{{sidebar_rel}}task_manager?state=busy"><b data-icon="{{g.icons['stats'].char}}" class="ico {{g.icons['stats'].css}}"></b>Task Manager</a></li>
-
-  </ul>
+  {% for s in c.site_admin_sidebar_menu %}
+    {{sidebar_item(s)}}
+  {% endfor %}
 </div>
 {% endblock %}
 
diff --git a/Allura/allura/templates/site_admin_index.html b/Allura/allura/templates/site_admin_index.html
index a03bed1..4fea844 100644
--- a/Allura/allura/templates/site_admin_index.html
+++ b/Allura/allura/templates/site_admin_index.html
@@ -18,14 +18,8 @@
 -#}
 {% set page="index" %}
 {% extends 'allura:templates/site_admin.html' %}
+
 {% block content %}
-<h2>Neighborhood Stats</h2>
-<table>
-  <thead>
-    <tr><th>Name</th><th>Projects</th><th>Configured</th></tr>
-  </thead>
-  {% for name, count, count_configured in neighborhoods %}
-  <tr><td>{{name}}</td><td>{{count}}</td><td>{{count_configured}}</td></tr>
-  {% endfor %}
-</table>
+<h2>Site Admin Home</h2>
+<p>Choose an action from the menu on the left.</p>
 {% endblock %}
diff --git a/Allura/allura/templates/site_admin_stats.html b/Allura/allura/templates/site_admin_stats.html
index 86262d5..6bdcf3b 100644
--- a/Allura/allura/templates/site_admin_stats.html
+++ b/Allura/allura/templates/site_admin_stats.html
@@ -20,35 +20,13 @@
 {% extends 'allura:templates/site_admin.html' %}
 
 {% block content %}
+<h2>Neighborhood Stats</h2>
 <table>
   <thead>
-    <tr>
-      <th>Url</th>
-      <th>Ming</th>
-      <th>Mongo</th>
-      <th>Render</th>
-      <th>Template</th>
-      <th>Total Time</th>
-    </tr>
-    <tr>
-      <th>Mean</th>
-        <td>{{agg_timings.ming}}</td>
-        <td>{{agg_timings.mongo}}</td>
-        <td>{{agg_timings.render}}</td>
-        <td>{{agg_timings.template}}</td>
-        <td>{{agg_timings.total}}</td>
-    </tr>
+    <tr><th>Name</th><th>Projects</th><th>Configured</th></tr>
   </thead>
-  {% for url,timers in stats %}
-  <tr>
-    <td>{{url}}</td>
-    <td>{{timers.get('ming')}}</td>
-    <td>{{timers.get('mongo')}}</td>
-    <td>{{timers.get('render')}}</td>
-    <td>{{timers.get('template')}}</td>
-    <td>{{timers.get('total')}}</td>
-  </tr>
+  {% for name, count, count_configured in neighborhoods %}
+  <tr><td>{{name}}</td><td>{{count}}</td><td>{{count_configured}}</td></tr>
   {% endfor %}
 </table>
-
 {% endblock %}
diff --git a/Allura/allura/tests/functional/test_site_admin.py b/Allura/allura/tests/functional/test_site_admin.py
index 0bbff58..79ca43b 100644
--- a/Allura/allura/tests/functional/test_site_admin.py
+++ b/Allura/allura/tests/functional/test_site_admin.py
@@ -39,6 +39,11 @@
     def test_home(self):
         r = self.app.get('/nf/admin/', extra_environ=dict(
             username='root'))
+        assert 'Site Admin Home' in r
+
+    def test_stats(self):
+        r = self.app.get('/nf/admin/stats/', extra_environ=dict(
+            username='root'))
         assert 'Forge Site Admin' in r.html.find(
             'h2', {'class': 'dark title'}).contents[0]
         stats_table = r.html.find('table')
diff --git a/Allura/docs/extending.rst b/Allura/docs/extending.rst
index 9758f55..1e7cef7 100644
--- a/Allura/docs/extending.rst
+++ b/Allura/docs/extending.rst
@@ -31,6 +31,7 @@
 * :class:`allura.lib.plugin.AuthenticationProvider`
 * :class:`allura.lib.plugin.UserPreferencesProvider`
 * :class:`allura.lib.plugin.AdminExtension`
+* :class:`allura.lib.plugin.SiteAdminExtension`
 * :class:`allura.lib.spam.SpamFilter`
 * ``site_stats`` in the root API data.  Docs in :class:`allura.controllers.rest.RestController`
 * :mod:`allura.lib.package_path_loader` (for overriding templates)
diff --git a/Allura/setup.py b/Allura/setup.py
index f4ea03a..cfb4649 100644
--- a/Allura/setup.py
+++ b/Allura/setup.py
@@ -124,6 +124,9 @@
     akismet = allura.lib.spam.akismetfilter:AkismetSpamFilter
     mollom = allura.lib.spam.mollomfilter:MollomSpamFilter
 
+    [allura.site_admin]
+    stats = allura.controllers.site_admin:StatsSiteAdminExtension
+
     [paste.paster_command]
     taskd = allura.command.taskd:TaskdCommand
     taskd_cleanup = allura.command.taskd_cleanup:TaskdCleanupCommand