BryanDavis has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/364136 )

Change subject: Refactor striker.tools.views
......................................................................

Refactor striker.tools.views

The striker.tools.views module was getting to be quite long, so this
change splits it into several smaller modules.

Change-Id: If9d20212f1dac651968b1f645a636c13e4294c56
---
M striker/tools/urls.py
M striker/tools/utils.py
D striker/tools/views.py
A striker/tools/views/__init__.py
A striker/tools/views/decorators.py
A striker/tools/views/membership.py
A striker/tools/views/repo.py
A striker/tools/views/tool.py
A striker/tools/views/toolinfo.py
9 files changed, 1,132 insertions(+), 956 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/labs/striker 
refs/changes/36/364136/1

diff --git a/striker/tools/urls.py b/striker/tools/urls.py
index 7a56500..e6378a4 100644
--- a/striker/tools/urls.py
+++ b/striker/tools/urls.py
@@ -20,7 +20,10 @@
 
 from django.conf import urls
 
-import striker.tools.views
+from striker.tools.views.toolinfo import HistoryView
+from striker.tools.views.toolinfo import TagAutocomplete
+from striker.tools.views.tool import MaintainerAutocomplete
+from striker.tools.views.tool import ToolUserAutocomplete
 
 
 TOOL = r'(?P<tool>[_a-z][-0-9_a-z]*)'
@@ -32,12 +35,12 @@
     urls.url(r'^$', 'striker.tools.views.index', name='index'),
     urls.url(
         r'^id/{tool}$'.format(tool=TOOL),
-        'striker.tools.views.tool',
+        'striker.tools.views.tool.view',
         name='tool'
     ),
     urls.url(
         r'^id/{tool}/info/create$'.format(tool=TOOL),
-        'striker.tools.views.info_create',
+        'striker.tools.views.toolinfo.create',
         name='info_create'
     ),
     urls.url(
@@ -45,7 +48,7 @@
             tool=TOOL,
             info_id=INFO_ID,
         ),
-        'striker.tools.views.info_read',
+        'striker.tools.views.toolinfo.read',
         name='info_read'
     ),
     urls.url(
@@ -53,7 +56,7 @@
             tool=TOOL,
             info_id=INFO_ID,
         ),
-        'striker.tools.views.info_edit',
+        'striker.tools.views.toolinfo.edit',
         name='info_edit'
     ),
     urls.url(
@@ -61,7 +64,7 @@
             tool=TOOL,
             info_id=INFO_ID,
         ),
-        striker.tools.views.ToolInfoHistoryView.as_view(),
+        HistoryView.as_view(),
         name='info_history'
     ),
     urls.url(
@@ -70,7 +73,7 @@
             info_id=INFO_ID,
             version_id=VERSION_ID,
         ),
-        'striker.tools.views.info_revision',
+        'striker.tools.views.toolinfo.revision',
         name='info_revision'
     ),
     urls.url(
@@ -79,52 +82,52 @@
             info_id=INFO_ID,
             version_id=VERSION_ID,
         ),
-        'striker.tools.views.info_revision',
+        'striker.tools.views.toolinfo.revision',
         name='info_admin'
     ),
     urls.url(
         r'^id/{tool}/repos/create$'.format(tool=TOOL),
-        'striker.tools.views.repo_create',
+        'striker.tools.views.repo.create',
         name='repo_create'
     ),
     urls.url(
         r'^id/{tool}/repos/id/{repo}$'.format(tool=TOOL, repo=REPO),
-        'striker.tools.views.repo_view',
+        'striker.tools.views.repo.view',
         name='repo_view'
     ),
     urls.url(
         r'^id/{tool}/maintainers/$'.format(tool=TOOL),
-        'striker.tools.views.maintainers',
+        'striker.tools.views.tool.maintainers',
         name='maintainers'
     ),
     urls.url(
         r'^membership/$',
-        'striker.tools.views.membership',
+        'striker.tools.views.membership.membership',
         name='membership'
     ),
     urls.url(
         r'^membership/apply$',
-        'striker.tools.views.membership_apply',
+        'striker.tools.views.membership.apply',
         name='membership_apply'
     ),
     urls.url(
         r'^membership/status/(?P<app_id>\d+)$',
-        'striker.tools.views.membership_status',
+        'striker.tools.views.membership.status',
         name='membership_status'
     ),
     urls.url(
         r'tags/autocomplete/$',
-        striker.tools.views.ToolInfoTagAutocomplete.as_view(),
+        TagAutocomplete.as_view(),
         name='tags_autocomplete'
     ),
     urls.url(
         r'toolinfo/v1/toolinfo.json$',
-        'striker.tools.views.toolinfo',
+        'striker.tools.views.toolinfo.json_v1',
         name='toolinfo'
     ),
     urls.url(
         r'create/$',
-        'striker.tools.views.tool_create',
+        'striker.tools.views.tool.create',
         name='tool_create'
     ),
     urls.url(r'^api/', urls.include(
@@ -132,15 +135,16 @@
             'striker.tools.views',
             urls.url(
                 r'^autocomplete/maintainer$',
-                striker.tools.views.MaintainerAutocomplete.as_view(),
+                MaintainerAutocomplete.as_view(),
                 name='maintainer'),
             urls.url(
                 r'^autocomplete/tooluser$',
-                striker.tools.views.ToolUserAutocomplete.as_view(),
+                ToolUserAutocomplete.as_view(),
                 name='tooluser'),
             urls.url(
                 r'^toolname/(?P<name>.+)$',
-                'toolname_available', name='toolname'),
+                'striker.tools.views.tool.name_available',
+                name='toolname'),
         ),
         namespace='api'
     )),
diff --git a/striker/tools/utils.py b/striker/tools/utils.py
index 0e99450..9ab6fa7 100644
--- a/striker/tools/utils.py
+++ b/striker/tools/utils.py
@@ -20,6 +20,7 @@
 
 import logging
 
+from django.conf import settings
 from django.contrib.auth.models import Group
 
 from striker.labsauth.utils import get_next_gid
@@ -96,3 +97,17 @@
             'Failed to add %s to django maintainers group', user)
 
     return tool
+
+
+def member_or_admin(tool, user):
+    """Is the given user a member of the given tool or a global admin?"""
+    if user.is_anonymous():
+        return False
+    if user.ldap_dn in tool.members:
+        return True
+    return user.ldap_dn in Tool.objects.get(cn='tools.admin').members
+
+
+def project_member(user):
+    groups = user.groups.values_list('name', flat=True)
+    return settings.TOOLS_TOOL_LABS_GROUP_NAME in groups
diff --git a/striker/tools/views.py b/striker/tools/views.py
deleted file mode 100644
index 15154a0..0000000
--- a/striker/tools/views.py
+++ /dev/null
@@ -1,936 +0,0 @@
-# -*- coding: utf-8 -*-
-#
-# Copyright (c) 2016 Wikimedia Foundation and contributors.
-# All Rights Reserved.
-#
-# This file is part of Striker.
-#
-# Striker is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# Striker is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Striker.  If not, see <http://www.gnu.org/licenses/>.
-
-import itertools
-import functools
-import logging
-
-from django import shortcuts
-from django.conf import settings
-from django.contrib import messages
-from django.contrib.auth.decorators import login_required
-from django.contrib.auth.models import Group
-from django.core import paginator
-from django.core import urlresolvers
-from django.core.exceptions import ObjectDoesNotExist
-from django.core.exceptions import PermissionDenied
-from django.core.serializers.json import DjangoJSONEncoder
-from django.db import transaction
-from django.db.utils import DatabaseError
-from django.http import HttpResponseRedirect
-from django.http import JsonResponse
-from django.utils import timezone
-from django.utils.text import slugify
-from django.utils.translation import ugettext_lazy as _
-from django.views.decorators.cache import never_cache
-
-from dal import autocomplete
-from notifications.signals import notify
-import reversion
-import reversion.models
-import reversion_compare.views
-
-from striker import mediawiki
-from striker import openstack
-from striker import phabricator
-from striker.labsauth.models import LabsUser
-from striker.tools import cache
-from striker.tools import utils
-from striker.tools.forms import AccessRequestAdminForm
-from striker.tools.forms import AccessRequestForm
-from striker.tools.forms import MantainersForm
-from striker.tools.forms import RepoCreateForm
-from striker.tools.forms import ToolCreateForm
-from striker.tools.forms import ToolInfoForm
-from striker.tools.forms import ToolInfoPublicForm
-from striker.tools.models import AccessRequest
-from striker.tools.models import DiffusionRepo
-from striker.tools.models import Maintainer
-from striker.tools.models import Tool
-from striker.tools.models import ToolInfo
-from striker.tools.models import ToolInfoTag
-from striker.tools.models import ToolUser
-
-
-WELCOME_MSG = "== Welcome to Toolforge! ==\n{{subst:ToolsGranted}}"
-WELCOME_SUMMARY = 'Welcome to Toolforge!'
-
-logger = logging.getLogger(__name__)
-phab = phabricator.Client.default_client()
-openstack = openstack.Client.default_client()
-
-
-class HttpResponseSeeOther(HttpResponseRedirect):
-    """HTTP redirect response with 303 status code"""
-    status_code = 303
-
-
-def see_other(to, *args, **kwargs):
-    """Redirect to another page with 303 status code."""
-    return HttpResponseSeeOther(shortcuts.resolve_url(to, *args, **kwargs))
-
-
-def inject_tool(f):
-    """Inject a Tool into the wrapped function in place of a 'tool' kwarg."""
-    @functools.wraps(f)
-    def decorated(*args, **kwargs):
-        if 'tool' in kwargs:
-            name = kwargs['tool']
-            try:
-                kwargs['tool'] = Tool.objects.get(cn='tools.{0}'.format(name))
-            except ObjectDoesNotExist:
-                req = args[0]
-                messages.error(
-                    req, _('Tool {tool} not found').format(tool=name))
-                return shortcuts.redirect(
-                    urlresolvers.reverse('tools:index'))
-        return f(*args, **kwargs)
-    return decorated
-
-
-def member_or_admin(tool, user):
-    """Is the given user a member of the given tool or a global admin?"""
-    if user.is_anonymous():
-        return False
-    if user.ldap_dn in tool.members:
-        return True
-    return user.ldap_dn in Tool.objects.get(cn='tools.admin').members
-
-
-def project_member(user):
-    groups = user.groups.values_list('name', flat=True)
-    return settings.TOOLS_TOOL_LABS_GROUP_NAME in groups
-
-
-def require_tools_member(f):
-    """Ensure that the active user is a member of the tools project."""
-    @functools.wraps(f)
-    def decorated(request, *args, **kwargs):
-        if project_member(request.user):
-            return f(request, *args, **kwargs)
-        raise PermissionDenied
-    return decorated
-
-
-def index(req):
-    ctx = {
-        'my_tools': [],
-        'query': req.GET.get('q', ''),
-        'member': False,
-    }
-    if not req.user.is_anonymous():
-        # TODO: do we need to paginate the user's tools too? Magnus has 60!
-        ctx['my_tools'] = Tool.objects.filter(
-            members__contains=req.user.ldap_dn).order_by('cn')
-        ctx['member'] = project_member(req.user)
-
-    page = req.GET.get('p')
-    if ctx['query'] == '':
-        tool_list = Tool.objects.all()
-    else:
-        tool_list = Tool.objects.filter(cn__icontains=ctx['query'])
-    tool_list = tool_list.order_by('cn')
-    pager = paginator.Paginator(tool_list, 10)
-    try:
-        tools = pager.page(page)
-    except paginator.PageNotAnInteger:
-        tools = pager.page(1)
-    except paginator.EmptyPage:
-        tools = pager.page(pager.num_pages)
-    ctx['all_tools'] = tools
-
-    return shortcuts.render(req, 'tools/index.html', ctx)
-
-
-@inject_tool
-def tool(req, tool):
-    return shortcuts.render(req, 'tools/tool.html', {
-        'tool': tool,
-        'toolinfo': tool.toolinfo(),
-        'repos': DiffusionRepo.objects.filter(tool=tool.name),
-        'can_edit': member_or_admin(tool, req.user),
-    })
-
-
[email protected]_revision()
-@login_required
-@inject_tool
-def info_create(req, tool):
-    """Create a ToolInfo record."""
-    if not member_or_admin(tool, req.user):
-        messages.error(
-            req, _('You are not a member of {tool}').format(tool=tool.name))
-        return shortcuts.redirect(tool.get_absolute_url())
-
-    initial_values = {
-        'name': tool.name,
-        'author': req.user,
-    }
-    if ToolInfo.objects.filter(tool=tool.name).count():
-        initial_values['name'] = '{}-'.format(tool.name)
-    form = ToolInfoForm(
-        req.POST or None, req.FILES or None, initial=initial_values)
-    if req.method == 'POST':
-        if form.is_valid():
-            try:
-                reversion.set_comment(form.cleaned_data['comment'])
-                toolinfo = form.save(commit=False)
-                toolinfo.tool = tool.name
-                toolinfo.save()
-                form.save_m2m()
-                reversion.add_to_revision(toolinfo)
-                messages.info(
-                    req, _("Toolinfo {} created".format(toolinfo.title)))
-                return shortcuts.redirect(
-                    urlresolvers.reverse('tools:tool', kwargs={
-                        'tool': tool.name,
-                    }))
-            except DatabaseError:
-                logger.exception('ToolInfo.save failed')
-                messages.error(
-                    req,
-                    _("Error updating database. [req id: {id}]").format(
-                        id=req.id))
-    ctx = {
-        'tool': tool,
-        'form': form,
-    }
-    return shortcuts.render(req, 'tools/info/create.html', ctx)
-
-
-@inject_tool
-def info_read(req, tool, info_id):
-    """View a ToolInfo record."""
-    toolinfo = shortcuts.get_object_or_404(ToolInfo, pk=info_id, tool=tool)
-    ctx = {
-        'tool': tool,
-        'toolinfo': toolinfo,
-    }
-    return shortcuts.render(req, 'tools/info/read.html', ctx)
-
-
[email protected]_revision()
-@login_required
-@inject_tool
-def info_edit(req, tool, info_id):
-    """Create a ToolInfo record."""
-    toolinfo = shortcuts.get_object_or_404(ToolInfo, pk=info_id, tool=tool)
-    if member_or_admin(tool, req.user):
-        form = ToolInfoForm(
-            req.POST or None, req.FILES or None, instance=toolinfo)
-    else:
-        form = ToolInfoPublicForm(
-            req.POST or None, req.FILES or None, instance=toolinfo)
-
-    if req.method == 'POST':
-        if form.is_valid():
-            try:
-                reversion.set_comment(form.cleaned_data['comment'])
-                toolinfo = form.save()
-                reversion.add_to_revision(toolinfo)
-                messages.info(
-                    req, _("Toolinfo {} updated".format(toolinfo.title)))
-                return shortcuts.redirect(
-                    urlresolvers.reverse('tools:tool', kwargs={
-                        'tool': tool.name,
-                    }))
-            except DatabaseError:
-                logger.exception('ToolInfo.save failed')
-                messages.error(
-                    req,
-                    _("Error updating database. [req id: {id}]").format(
-                        id=req.id))
-    ctx = {
-        'tool': tool,
-        'toolinfo': toolinfo,
-        'form': form,
-    }
-    return shortcuts.render(req, 'tools/info/update.html', ctx)
-
-
-class ToolInfoHistoryView(reversion_compare.views.HistoryCompareDetailView):
-    model = ToolInfo
-    pk_url_kwarg = 'info_id'
-    template_name = 'tools/info/history.html'
-
-    def get_queryset(self):
-        qs = super(ToolInfoHistoryView, self).get_queryset()
-        return qs.filter(tool=self.kwargs['tool'])
-
-    def _get_action_list(self):
-        actions = super(ToolInfoHistoryView, self)._get_action_list()
-        for action in actions:
-            action['url'] = urlresolvers.reverse(
-                'tools:info_revision',
-                kwargs={
-                    'tool': self.kwargs['tool'],
-                    'info_id': self.kwargs['info_id'],
-                    'version_id': action['version'].pk,
-                },
-            )
-        return actions
-
-    def get_context_data(self, **kwargs):
-        user = self.request.user
-        tool = Tool.objects.get(cn='tools.{0}'.format(kwargs['object'].tool))
-
-        ctx = super(ToolInfoHistoryView, self).get_context_data(**kwargs)
-        ctx['toolinfo'] = kwargs['object']
-        ctx['tool'] = tool
-        ctx['show_suppressed'] = member_or_admin(tool, user)
-        return ctx
-
-
-@inject_tool
-def info_revision(req, tool, info_id, version_id):
-    """View/revert/suppress a particular version of a ToolInfo model."""
-    tool = shortcuts.get_object_or_404(Tool, cn='tools.{}'.format(tool))
-    toolinfo = shortcuts.get_object_or_404(ToolInfo, pk=info_id, tool=tool)
-    version = shortcuts.get_object_or_404(
-        reversion.models.Version, pk=version_id, object_id=info_id)
-
-    can_revert = member_or_admin(tool, req.user)
-    can_suppress = member_or_admin(tool, req.user)
-
-    history_url = urlresolvers.reverse(
-        'tools:info_history',
-        kwargs={
-            'tool': tool.name,
-            'info_id': info_id,
-        })
-
-    if req.method == 'POST' and (
-            '_hide' in req.POST or
-            '_show' in req.POST
-    ):
-        if can_suppress:
-            try:
-                version.suppressed = '_hide' in req.POST
-                version.save()
-                if version.suppressed:
-                    msg = _("Revision {id} hidden")
-                else:
-                    msg = _("Revision {id} shown")
-                messages.info(req, msg.format(id=version_id))
-            except DatabaseError:
-                logger.exception('Revision.suppress failed')
-                messages.error(
-                    req,
-                    _("Error updating database. [req id: {id}]").format(
-                        id=req.id))
-        else:
-            messages.error(req, _("Tool membership required"))
-        return shortcuts.redirect(history_url)
-
-    try:
-        # This try/except block is pretty gross, but its the way that
-        # django-reversion provides to get a historic model. We start a db
-        # transaction, revert the revision, grab the model from the db, render
-        # it to a response, and then wrap that rendered response in an
-        # exception. We raise the exception to trigger a rollback of the
-        # transaction (gross), and then catch the exception and return the
-        # wrapped response.
-        with transaction.atomic(using=version.db):
-            version.revision.revert()
-            # Fetch the toolinfo again now that it hav been reverted
-            toolinfo = shortcuts.get_object_or_404(
-                ToolInfo, pk=info_id, tool=tool.name)
-
-            if req.method == 'POST':
-                if '_revert' in req.POST:
-                    if not can_revert:
-                        messages.error(req, _("Tool membership required"))
-                        raise reversion.views._RollBackRevisionView(None)
-                    try:
-                        with reversion.create_revision():
-                            dt = version.revision.date_created.strftime(
-                                '%Y-%m-%dT%H:%M:%S%z')
-                            reversion.set_user(req.user)
-                            reversion.set_comment(
-                                '{} reverted to version saved on {}'.format(
-                                    req.user,
-                                    dt))
-                            toolinfo.save()
-                            messages.info(
-                                req,
-                                _("Toolinfo {} reverted to {}".format(
-                                    toolinfo.title,
-                                    dt)))
-                            # Return instead of raise so transactions are
-                            # committed
-                            return shortcuts.redirect(history_url)
-                    except DatabaseError:
-                        logger.exception('ToolInfo.revert failed')
-                        messages.error(
-                            req,
-                            _(
-                                "Error updating database. [req id: {id}]"
-                            ).format(id=req.id))
-                        raise reversion.views._RollBackRevisionView(None)
-
-            ctx = {
-                'tool': tool,
-                'toolinfo': toolinfo,
-                'version': version,
-                'can_revert': can_revert,
-                'can_suppress': can_suppress,
-            }
-            resp = shortcuts.render(req, 'tools/info/revision.html', ctx)
-            raise reversion.views._RollBackRevisionView(resp)
-    except reversion.errors.RevertError:
-        logger.exception('ToolInfo.revert failed')
-        return shortcuts.redirect(history_url)
-
-    except reversion.views._RollBackRevisionView as ex:
-        if ex.response:
-            return ex.response
-        else:
-            return shortcuts.redirect(history_url)
-
-
-@login_required
-@inject_tool
-def repo_create(req, tool):
-    """Create a new Diffusion repo."""
-    if not member_or_admin(tool, req.user):
-        messages.error(
-            req, _('You are not a member of {tool}').format(tool=tool.name))
-        return shortcuts.redirect(urlresolvers.reverse('tools:index'))
-
-    form = RepoCreateForm(req.POST or None, req.FILES or None, tool=tool)
-    if req.method == 'POST':
-        if form.is_valid():
-            name = form.cleaned_data['repo_name']
-            # FIXME: error handling!
-            # * You can not select this edit policy, because you would no
-            #   longer be able to edit the object. (ERR-CONDUIT-CORE)
-            # Convert list of maintainers to list of phab users
-            maintainers = [m.full_name for m in tool.maintainers()]
-            try:
-                phab_maintainers = [m['phid'] for m in phab.user_ldapquery(
-                    maintainers)]
-            except KeyError:
-                messages.error(
-                    req, 'No Phabricator accounts found for tool maintainers.')
-            else:
-                # Create repo
-                # FIXME: error handling!
-                repo = phab.create_repository(name, phab_maintainers)
-                # Save a local association between the repo and the tool.
-                repo_model = DiffusionRepo(
-                    tool=tool.name, name=name, phid=repo['phid'],
-                    created_by=req.user)
-                try:
-                    repo_model.save()
-                    try:
-                        maintainers = Group.objects.get(name=tool.cn)
-                    except ObjectDoesNotExist:
-                        # Can't find group for the tool
-                        pass
-                    else:
-                        notify.send(
-                            recipient=Group.objects.get(name=tool.cn),
-                            sender=req.user,
-                            verb=_('created new repo'),
-                            target=repo_model,
-                            public=True,
-                            level='info',
-                            actions=[
-                                {
-                                    'title': _('View repository'),
-                                    'href': repo_model.get_absolute_url(),
-                                },
-                            ],
-                        )
-                    # Redirect to repo view
-                    return shortcuts.redirect(
-                        urlresolvers.reverse('tools:repo_view', kwargs={
-                            'tool': tool.name,
-                            'repo': name,
-                        }))
-                except DatabaseError:
-                    logger.exception('repo_model.save failed')
-                    messages.error(
-                        req,
-                        _("Error updating database. [req id: {id}]").format(
-                            id=req.id))
-
-    ctx = {
-        'tool': tool,
-        'form': form,
-    }
-    return shortcuts.render(req, 'tools/repo/create.html', ctx)
-
-
-@inject_tool
-def repo_view(req, tool, repo):
-    ctx = {
-        'tool': tool,
-        'repo': repo,
-        'repo_id': None,
-        'status': 'unknown',
-        'urls': [],
-        'policy': {'view': None, 'edit': None, 'push': None},
-        'phab_url': settings.PHABRICATOR_URL,
-    }
-    try:
-        repository = phab.get_repository(repo)
-        ctx['repo_id'] = repository['id']
-        ctx['status'] = repository['fields']['status']
-        ctx['urls'] = [
-            u['fields']['uri']['display'] for u in
-            repository['attachments']['uris']['uris']
-            if u['fields']['display']['effective'] == 'always'
-        ]
-
-        # Lookup policy details
-        policy = repository['fields']['policy']
-        policies = phab.get_policies(list(set(policy.values())))
-        ctx['policy']['view'] = policies[policy['view']]
-        ctx['policy']['edit'] = policies[policy['edit']]
-        ctx['policy']['push'] = policies[policy['diffusion.push']]
-
-        # Lookup phid details for custom rules
-        phids = []
-        for p in policies.values():
-            if p['type'] == 'custom':
-                for r in p['rules']:
-                    phids.extend(r['value'])
-        ctx['phids'] = phab.get_phids(list(set(phids)))
-    except KeyError:
-        pass
-    except phabricator.APIError as e:
-        logger.error(e)
-
-    return shortcuts.render(req, 'tools/repo.html', ctx)
-
-
-def membership(req):
-    """Show access requests."""
-    ctx = {
-        'o': req.GET.get('o', '-created_date'),
-        'cols': [
-            {'field': 'created_date', 'label': 'Created'},
-            {'field': 'user', 'label': 'User'},
-            {'field': 'status', 'label': 'Status'},
-        ],
-    }
-    if req.user.is_staff:
-        all_requests = AccessRequest.objects.all()
-    else:
-        all_requests = AccessRequest.objects.filter(suppressed=False)
-    all_requests = all_requests.order_by(ctx['o'])
-    pager = paginator.Paginator(all_requests, 25)
-    page = req.GET.get('p', 1)
-    try:
-        access_requests = pager.page(page)
-    except paginator.PageNotAnInteger:
-        access_requests = pager.page(1)
-    except paginator.EmptyPage:
-        access_requests = pager.page(pager.num_pages)
-    ctx['access_requests'] = access_requests
-    return shortcuts.render(req, 'tools/membership.html', ctx)
-
-
-@login_required
-def membership_apply(req):
-    """Request membership in the Tools project."""
-    if project_member(req.user):
-        messages.error(
-            req, _('You are already a member of Toolforge'))
-        return see_other(urlresolvers.reverse('tools:index'))
-
-    pending = AccessRequest.objects.filter(
-            user=req.user, status=AccessRequest.PENDING)
-    if pending:
-        return see_other(
-            urlresolvers.reverse(
-                'tools:membership_status', args=[pending[0].id]))
-
-    form = AccessRequestForm(req.POST or None, req.FILES or None)
-    if req.method == 'POST':
-        if form.is_valid():
-            try:
-                request = form.save(commit=False)
-                request.user = req.user
-                request.save()
-                notify.send(
-                    recipient=Group.objects.get(name='tools.admin'),
-                    sender=req.user,
-                    verb=_('created'),
-                    target=request,
-                    public=False,
-                    description=request.reason,
-                    level='info',
-                    actions=[
-                        {
-                            'title': _('View request'),
-                            'href': request.get_absolute_url(),
-                        },
-                    ],
-                )
-                messages.info(
-                    req, _("Toolforge membership request submitted"))
-                return shortcuts.redirect(urlresolvers.reverse('tools:index'))
-            except DatabaseError:
-                logger.exception('AccessRequest.save failed')
-                messages.error(
-                    req,
-                    _("Error updating database. [req id: {id}]").format(
-                        id=req.id))
-    return shortcuts.render(req, 'tools/membership/apply.html', {'form': form})
-
-
-def membership_status(req, app_id):
-    """Show access request status and allow editing if authorized."""
-    request = shortcuts.get_object_or_404(AccessRequest, pk=app_id)
-    form = None
-    as_admin = False
-    if req.user == request.user and request.status == AccessRequest.PENDING:
-        # An applicant can amend their own request while it is pending
-        form = AccessRequestForm(
-                req.POST or None, req.FILES or None, instance=request)
-    elif req.user.is_staff:
-        # TODO: guard condition will need changing if/when striker handles
-        # more than tools
-        as_admin = True
-        form = AccessRequestAdminForm(
-                req.POST or None, req.FILES or None, instance=request)
-
-    if form is not None and req.method == 'POST':
-        if form.is_valid() and form.has_changed():
-            try:
-                request = form.save(commit=False)
-                if as_admin:
-                    if request.status == AccessRequest.APPROVED:
-                        openstack.grant_role(
-                            settings.OPENSTACK_USER_ROLE,
-                            request.user.shellname,
-                        )
-                        mwapi = mediawiki.Client.default_client()
-                        talk = mwapi.user_talk_page(request.user.ldapname)
-                        msg = '{}\n{}'.format(
-                            talk.text(), WELCOME_MSG).strip()
-                        talk.save(msg, summary=WELCOME_SUMMARY, bot=False)
-                        cache.purge_openstack_users()
-
-                    if request.status != AccessRequest.PENDING:
-                        request.resolved_by = req.user
-                        request.resolved_date = timezone.now()
-                    else:
-                        request.resolved_by = None
-                        request.resolved_date = None
-                request.save()
-
-                if as_admin:
-                    recipient = request.user
-                    verb = _('commented on')
-                    description = request.admin_notes
-                    level = 'info'
-                    if request.status != AccessRequest.PENDING:
-                        verb = request.get_status_display().lower()
-                        if request.status == AccessRequest.APPROVED:
-                            level = 'success'
-                        else:
-                            level = 'warning'
-                else:
-                    recipient = Group.objects.get(name='tools.admin')
-                    verb = _('updated')
-                    description = request.reason
-                    level = 'info'
-
-                notify.send(
-                    recipient=recipient,
-                    sender=req.user,
-                    verb=verb,
-                    target=request,
-                    public=False,
-                    description=description,
-                    level=level,
-                    actions=[
-                        {
-                            'title': _('View request'),
-                            'href': request.get_absolute_url(),
-                        },
-                    ],
-                )
-
-                messages.info(
-                    req, _("Toolforge membership request updated"))
-                return shortcuts.redirect(
-                    urlresolvers.reverse(
-                        'tools:membership_status', args=[request.id]))
-            except DatabaseError:
-                logger.exception('AccessRequest.save failed')
-                messages.error(
-                    req,
-                    _("Error updating database. [req id: {id}]").format(
-                        id=req.id))
-    ctx = {
-        'app': request,
-        'form': form,
-        'wikitech': settings.WIKITECH_URL,
-        'meta': settings.OAUTH_MWURL,
-    }
-    return shortcuts.render(req, 'tools/membership/status.html', ctx)
-
-
-class ToolInfoTagAutocomplete(autocomplete.Select2QuerySetView):
-    create_field = 'name'
-
-    def get_queryset(self):
-        if not self.request.user.is_authenticated():
-            return ToolInfoTag.objects.none()
-        qs = ToolInfoTag.objects.all()
-        if self.q:
-            qs = qs.filter(name__icontains=self.q)
-        qs.order_by('name')
-        return qs
-
-    def has_add_permission(self, request):
-        return request.user.is_authenticated()
-
-    def create_object(self, text):
-        return ToolInfoTag.objects.create(name=text, slug=slugify(text))
-
-
-def toolinfo(req):
-    class PrettyPrintJSONEncoder(DjangoJSONEncoder):
-        def __init__(self, *args, **kwargs):
-            kwargs['indent'] = 2
-            kwargs['separators'] = (',', ':')
-            super(PrettyPrintJSONEncoder, self).__init__(*args, **kwargs)
-
-    return JsonResponse(
-        [
-            info.toolinfo()
-            for info in ToolInfo.objects.all().order_by('name')
-        ],
-        encoder=PrettyPrintJSONEncoder,
-        safe=False,
-    )
-
-
-@require_tools_member
-@login_required
-def tool_create(req):
-    form = ToolCreateForm(req.POST or None, req.FILES or None)
-    if req.method == 'POST':
-        if form.is_valid():
-            try:
-                tool = utils.create_tool(form.cleaned_data['name'], req.user)
-            except Exception:
-                logger.exception('utils.create_tool failed')
-                messages.error(
-                    req,
-                    _("Error creating tool. [req id: {id}]").format(
-                        id=req.id))
-            else:
-                messages.info(
-                    req, _("Tool {} created".format(tool.name)))
-                try:
-                    with reversion.create_revision():
-                        toolinfo = ToolInfo(
-                            name='toolforge.{}'.format(
-                                form.cleaned_data['name']),
-                            tool=form.cleaned_data['name'],
-                            title=form.cleaned_data['title'],
-                            description=form.cleaned_data['description'],
-                            license=form.cleaned_data['license'],
-                            is_webservice=False,
-                        )
-                        reversion.set_user(req.user)
-                        reversion.set_comment('Tool created')
-                        toolinfo.save()
-                        toolinfo.authors.add(req.user)
-                        if form.cleaned_data['tags']:
-                            toolinfo.tags.add(*form.cleaned_data['tags'])
-                except Exception:
-                    logger.exception('ToolInfo create failed')
-                    messages.error(
-                        req,
-                        _("Error creating toolinfo. [req id: {id}]").format(
-                            id=req.id))
-                try:
-                    maintainers = Group.objects.get(name=tool.cn)
-                except ObjectDoesNotExist:
-                    # Can't find group for the tool
-                    pass
-                else:
-                    # Do not set tool as the notification target because the
-                    # framework does not understand LDAP models.
-                    notify.send(
-                        recipient=maintainers,
-                        sender=req.user,
-                        verb=_('created tool {}').format(tool.name),
-                        public=True,
-                        level='info',
-                        actions=[
-                            {
-                                'title': _('View tool'),
-                                'href': tool.get_absolute_url(),
-                            },
-                        ],
-                    )
-
-                return shortcuts.redirect(tool.get_absolute_url())
-    ctx = {
-        'form': form,
-    }
-    return shortcuts.render(req, 'tools/create.html', ctx)
-
-
-@never_cache
-def toolname_available(req, name):
-    """JSON callback for parsley validation of tool name.
-
-    Kind of gross, but it returns a 406 status code when the name is not
-    available. This is to work with the limited choice of default response
-    validators in parsley.
-    """
-    available = utils.toolname_available(name)
-    if available:
-        available = utils.check_toolname_create(name)['ok']
-    status = 200 if available else 406
-    return JsonResponse({
-        'available': available,
-    }, status=status)
-
-
-@login_required
-@inject_tool
-def maintainers(req, tool):
-    """Manage the maintainers list for a tool"""
-    if not member_or_admin(tool, req.user):
-        messages.error(
-            req, _('You are not a member of {tool}').format(tool=tool.name))
-        return shortcuts.redirect(tool.get_absolute_url())
-    form = MantainersForm(req.POST or None, req.FILES or None, tool=tool)
-    if req.method == 'POST':
-        if form.is_valid():
-            old_members = set(tool.members)
-            new_members = set(
-                m.dn for m in itertools.chain(
-                    form.cleaned_data['maintainers'],
-                    form.cleaned_data['tools']
-                )
-            )
-            tool.members = new_members
-            tool.save()
-
-            maintainers, created = Group.objects.get_or_create(name=tool.cn)
-            added_maintainers = list(new_members - old_members)
-            removed_maintainers = list(old_members - new_members)
-            for dn in added_maintainers:
-                uid = dn.split(',')[0][4:]
-                logging.info('Added %s', uid)
-                try:
-                    added = LabsUser.objects.get(shellname=uid)
-                except ObjectDoesNotExist:
-                    # No local user for this account
-                    logging.info('No LabsUser found for %s', uid)
-                    pass
-                else:
-                    # Add user to the mirrored group
-                    added.groups.add(maintainers.id)
-                    # Do not set tool as the notification target because the
-                    # framework does not understand LDAP models.
-                    notify.send(
-                        recipient=added,
-                        sender=req.user,
-                        verb=_(
-                            'added you as a maintainer of {}'
-                        ).format(tool.name),
-                        public=True,
-                        level='info',
-                        actions=[
-                            {
-                                'title': _('View tool'),
-                                'href': tool.get_absolute_url(),
-                            },
-                        ],
-                    )
-
-            for dn in removed_maintainers:
-                uid = dn.split(',')[0][4:]
-                logging.info('Removed %s', uid)
-                try:
-                    removed = LabsUser.objects.get(shellname=uid)
-                except ObjectDoesNotExist:
-                    # No local user for this account
-                    logging.info('No LabsUser found for %s', uid)
-                    pass
-                else:
-                    # Add user to the mirrored group
-                    removed.groups.remove(maintainers.id)
-                    # Do not set tool as the notification target because the
-                    # framework does not understand LDAP models.
-                    notify.send(
-                        recipient=removed,
-                        sender=req.user,
-                        verb=_(
-                            'removed you from the maintainers of {}'
-                        ).format(tool.name),
-                        public=True,
-                        level='info',
-                        actions=[
-                            {
-                                'title': _('View tool'),
-                                'href': tool.get_absolute_url(),
-                            },
-                        ],
-                    )
-
-            messages.info(req, _('Maintainers updated'))
-            return shortcuts.redirect(tool.get_absolute_url())
-
-    ctx = {
-        'tool': tool,
-        'form': form,
-    }
-    return shortcuts.render(req, 'tools/maintainers.html', ctx)
-
-
-class MaintainerAutocomplete(autocomplete.Select2QuerySetView):
-    def get_queryset(self):
-        if not self.request.user.is_authenticated():
-            return Maintainer.objects.none()
-        qs = Maintainer.objects.all()
-        if self.q:
-            qs = qs.filter(cn__icontains=self.q)
-        qs.order_by('cn')
-        return qs
-
-    def get_result_label(self, result):
-        return result.cn
-
-
-class ToolUserAutocomplete(autocomplete.Select2QuerySetView):
-    def get_queryset(self):
-        if not self.request.user.is_authenticated():
-            return ToolUser.objects.none()
-        qs = ToolUser.objects.all()
-        if self.q:
-            qs = qs.filter(uid__icontains=self.q)
-        qs.order_by('uid')
-        return qs
-
-    def get_result_label(self, result):
-        return result.name
diff --git a/striker/tools/views/__init__.py b/striker/tools/views/__init__.py
new file mode 100644
index 0000000..d35060a
--- /dev/null
+++ b/striker/tools/views/__init__.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2016 Wikimedia Foundation and contributors.
+# All Rights Reserved.
+#
+# This file is part of Striker.
+#
+# Striker is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Striker is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Striker.  If not, see <http://www.gnu.org/licenses/>.
+
+from django import shortcuts
+from django.core import paginator
+
+from striker.tools.models import Tool
+from striker.tools.utils import project_member
+
+
+def index(req):
+    ctx = {
+        'my_tools': [],
+        'query': req.GET.get('q', ''),
+        'member': False,
+    }
+    if not req.user.is_anonymous():
+        # TODO: do we need to paginate the user's tools too? Magnus has 60!
+        ctx['my_tools'] = Tool.objects.filter(
+            members__contains=req.user.ldap_dn).order_by('cn')
+        ctx['member'] = project_member(req.user)
+
+    page = req.GET.get('p')
+    if ctx['query'] == '':
+        tool_list = Tool.objects.all()
+    else:
+        tool_list = Tool.objects.filter(cn__icontains=ctx['query'])
+    tool_list = tool_list.order_by('cn')
+    pager = paginator.Paginator(tool_list, 10)
+    try:
+        tools = pager.page(page)
+    except paginator.PageNotAnInteger:
+        tools = pager.page(1)
+    except paginator.EmptyPage:
+        tools = pager.page(pager.num_pages)
+    ctx['all_tools'] = tools
+
+    return shortcuts.render(req, 'tools/index.html', ctx)
diff --git a/striker/tools/views/decorators.py 
b/striker/tools/views/decorators.py
new file mode 100644
index 0000000..96e417d
--- /dev/null
+++ b/striker/tools/views/decorators.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2016 Wikimedia Foundation and contributors.
+# All Rights Reserved.
+#
+# This file is part of Striker.
+#
+# Striker is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Striker is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Striker.  If not, see <http://www.gnu.org/licenses/>.
+
+import functools
+
+from django import shortcuts
+from django.contrib import messages
+from django.core import urlresolvers
+from django.core.exceptions import ObjectDoesNotExist
+from django.core.exceptions import PermissionDenied
+from django.utils.translation import ugettext_lazy as _
+
+from striker.tools.models import Tool
+from striker.tools.utils import project_member
+
+
+def inject_tool(f):
+    """Inject a Tool into the wrapped function in place of a 'tool' kwarg."""
+    @functools.wraps(f)
+    def decorated(*args, **kwargs):
+        if 'tool' in kwargs:
+            name = kwargs['tool']
+            try:
+                kwargs['tool'] = Tool.objects.get(cn='tools.{0}'.format(name))
+            except ObjectDoesNotExist:
+                req = args[0]
+                messages.error(
+                    req, _('Tool {tool} not found').format(tool=name))
+                return shortcuts.redirect(
+                    urlresolvers.reverse('tools:index'))
+        return f(*args, **kwargs)
+    return decorated
+
+
+def require_tools_member(f):
+    """Ensure that the active user is a member of the tools project."""
+    @functools.wraps(f)
+    def decorated(request, *args, **kwargs):
+        if project_member(request.user):
+            return f(request, *args, **kwargs)
+        raise PermissionDenied
+    return decorated
diff --git a/striker/tools/views/membership.py 
b/striker/tools/views/membership.py
new file mode 100644
index 0000000..04b6057
--- /dev/null
+++ b/striker/tools/views/membership.py
@@ -0,0 +1,230 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2016 Wikimedia Foundation and contributors.
+# All Rights Reserved.
+#
+# This file is part of Striker.
+#
+# Striker is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Striker is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Striker.  If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+
+from django import shortcuts
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.models import Group
+from django.core import paginator
+from django.core import urlresolvers
+from django.db.utils import DatabaseError
+from django.http import HttpResponseRedirect
+from django.utils import timezone
+from django.utils.translation import ugettext_lazy as _
+
+from notifications.signals import notify
+
+from striker import mediawiki
+from striker import openstack
+from striker.tools import cache
+from striker.tools.forms import AccessRequestAdminForm
+from striker.tools.forms import AccessRequestForm
+from striker.tools.models import AccessRequest
+from striker.tools.utils import project_member
+
+
+WELCOME_MSG = "== Welcome to Toolforge! ==\n{{subst:ToolsGranted}}"
+WELCOME_SUMMARY = 'Welcome to Toolforge!'
+
+logger = logging.getLogger(__name__)
+openstack = openstack.Client.default_client()
+
+
+class HttpResponseSeeOther(HttpResponseRedirect):
+    """HTTP redirect response with 303 status code"""
+    status_code = 303
+
+
+def see_other(to, *args, **kwargs):
+    """Redirect to another page with 303 status code."""
+    return HttpResponseSeeOther(shortcuts.resolve_url(to, *args, **kwargs))
+
+
+def membership(req):
+    """Show access requests."""
+    ctx = {
+        'o': req.GET.get('o', '-created_date'),
+        'cols': [
+            {'field': 'created_date', 'label': 'Created'},
+            {'field': 'user', 'label': 'User'},
+            {'field': 'status', 'label': 'Status'},
+        ],
+    }
+    if req.user.is_staff:
+        all_requests = AccessRequest.objects.all()
+    else:
+        all_requests = AccessRequest.objects.filter(suppressed=False)
+    all_requests = all_requests.order_by(ctx['o'])
+    pager = paginator.Paginator(all_requests, 25)
+    page = req.GET.get('p', 1)
+    try:
+        access_requests = pager.page(page)
+    except paginator.PageNotAnInteger:
+        access_requests = pager.page(1)
+    except paginator.EmptyPage:
+        access_requests = pager.page(pager.num_pages)
+    ctx['access_requests'] = access_requests
+    return shortcuts.render(req, 'tools/membership.html', ctx)
+
+
+@login_required
+def apply(req):
+    """Request membership in the Tools project."""
+    if project_member(req.user):
+        messages.error(
+            req, _('You are already a member of Toolforge'))
+        return see_other(urlresolvers.reverse('tools:index'))
+
+    pending = AccessRequest.objects.filter(
+            user=req.user, status=AccessRequest.PENDING)
+    if pending:
+        return see_other(
+            urlresolvers.reverse(
+                'tools:membership_status', args=[pending[0].id]))
+
+    form = AccessRequestForm(req.POST or None, req.FILES or None)
+    if req.method == 'POST':
+        if form.is_valid():
+            try:
+                request = form.save(commit=False)
+                request.user = req.user
+                request.save()
+                notify.send(
+                    recipient=Group.objects.get(name='tools.admin'),
+                    sender=req.user,
+                    verb=_('created'),
+                    target=request,
+                    public=False,
+                    description=request.reason,
+                    level='info',
+                    actions=[
+                        {
+                            'title': _('View request'),
+                            'href': request.get_absolute_url(),
+                        },
+                    ],
+                )
+                messages.info(
+                    req, _("Toolforge membership request submitted"))
+                return shortcuts.redirect(urlresolvers.reverse('tools:index'))
+            except DatabaseError:
+                logger.exception('AccessRequest.save failed')
+                messages.error(
+                    req,
+                    _("Error updating database. [req id: {id}]").format(
+                        id=req.id))
+    return shortcuts.render(req, 'tools/membership/apply.html', {'form': form})
+
+
+def status(req, app_id):
+    """Show access request status and allow editing if authorized."""
+    request = shortcuts.get_object_or_404(AccessRequest, pk=app_id)
+    form = None
+    as_admin = False
+    if req.user == request.user and request.status == AccessRequest.PENDING:
+        # An applicant can amend their own request while it is pending
+        form = AccessRequestForm(
+                req.POST or None, req.FILES or None, instance=request)
+    elif req.user.is_staff:
+        # TODO: guard condition will need changing if/when striker handles
+        # more than tools
+        as_admin = True
+        form = AccessRequestAdminForm(
+                req.POST or None, req.FILES or None, instance=request)
+
+    if form is not None and req.method == 'POST':
+        if form.is_valid() and form.has_changed():
+            try:
+                request = form.save(commit=False)
+                if as_admin:
+                    if request.status == AccessRequest.APPROVED:
+                        openstack.grant_role(
+                            settings.OPENSTACK_USER_ROLE,
+                            request.user.shellname,
+                        )
+                        mwapi = mediawiki.Client.default_client()
+                        talk = mwapi.user_talk_page(request.user.ldapname)
+                        msg = '{}\n{}'.format(
+                            talk.text(), WELCOME_MSG).strip()
+                        talk.save(msg, summary=WELCOME_SUMMARY, bot=False)
+                        cache.purge_openstack_users()
+
+                    if request.status != AccessRequest.PENDING:
+                        request.resolved_by = req.user
+                        request.resolved_date = timezone.now()
+                    else:
+                        request.resolved_by = None
+                        request.resolved_date = None
+                request.save()
+
+                if as_admin:
+                    recipient = request.user
+                    verb = _('commented on')
+                    description = request.admin_notes
+                    level = 'info'
+                    if request.status != AccessRequest.PENDING:
+                        verb = request.get_status_display().lower()
+                        if request.status == AccessRequest.APPROVED:
+                            level = 'success'
+                        else:
+                            level = 'warning'
+                else:
+                    recipient = Group.objects.get(name='tools.admin')
+                    verb = _('updated')
+                    description = request.reason
+                    level = 'info'
+
+                notify.send(
+                    recipient=recipient,
+                    sender=req.user,
+                    verb=verb,
+                    target=request,
+                    public=False,
+                    description=description,
+                    level=level,
+                    actions=[
+                        {
+                            'title': _('View request'),
+                            'href': request.get_absolute_url(),
+                        },
+                    ],
+                )
+
+                messages.info(
+                    req, _("Toolforge membership request updated"))
+                return shortcuts.redirect(
+                    urlresolvers.reverse(
+                        'tools:membership_status', args=[request.id]))
+            except DatabaseError:
+                logger.exception('AccessRequest.save failed')
+                messages.error(
+                    req,
+                    _("Error updating database. [req id: {id}]").format(
+                        id=req.id))
+    ctx = {
+        'app': request,
+        'form': form,
+        'wikitech': settings.WIKITECH_URL,
+        'meta': settings.OAUTH_MWURL,
+    }
+    return shortcuts.render(req, 'tools/membership/status.html', ctx)
diff --git a/striker/tools/views/repo.py b/striker/tools/views/repo.py
new file mode 100644
index 0000000..ad73da6
--- /dev/null
+++ b/striker/tools/views/repo.py
@@ -0,0 +1,160 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2016 Wikimedia Foundation and contributors.
+# All Rights Reserved.
+#
+# This file is part of Striker.
+#
+# Striker is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Striker is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Striker.  If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+
+from django import shortcuts
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.models import Group
+from django.core import urlresolvers
+from django.core.exceptions import ObjectDoesNotExist
+from django.db.utils import DatabaseError
+from django.utils.translation import ugettext_lazy as _
+
+from notifications.signals import notify
+
+from striker import phabricator
+from striker.tools.forms import RepoCreateForm
+from striker.tools.models import DiffusionRepo
+from striker.tools.utils import member_or_admin
+from striker.tools.views.decorators import inject_tool
+
+
+logger = logging.getLogger(__name__)
+phab = phabricator.Client.default_client()
+
+
+@login_required
+@inject_tool
+def create(req, tool):
+    """Create a new Diffusion repo."""
+    if not member_or_admin(tool, req.user):
+        messages.error(
+            req, _('You are not a member of {tool}').format(tool=tool.name))
+        return shortcuts.redirect(urlresolvers.reverse('tools:index'))
+
+    form = RepoCreateForm(req.POST or None, req.FILES or None, tool=tool)
+    if req.method == 'POST':
+        if form.is_valid():
+            name = form.cleaned_data['repo_name']
+            # FIXME: error handling!
+            # * You can not select this edit policy, because you would no
+            #   longer be able to edit the object. (ERR-CONDUIT-CORE)
+            # Convert list of maintainers to list of phab users
+            maintainers = [m.full_name for m in tool.maintainers()]
+            try:
+                phab_maintainers = [m['phid'] for m in phab.user_ldapquery(
+                    maintainers)]
+            except KeyError:
+                messages.error(
+                    req, 'No Phabricator accounts found for tool maintainers.')
+            else:
+                # Create repo
+                # FIXME: error handling!
+                repo = phab.create_repository(name, phab_maintainers)
+                # Save a local association between the repo and the tool.
+                repo_model = DiffusionRepo(
+                    tool=tool.name, name=name, phid=repo['phid'],
+                    created_by=req.user)
+                try:
+                    repo_model.save()
+                    try:
+                        maintainers = Group.objects.get(name=tool.cn)
+                    except ObjectDoesNotExist:
+                        # Can't find group for the tool
+                        pass
+                    else:
+                        notify.send(
+                            recipient=Group.objects.get(name=tool.cn),
+                            sender=req.user,
+                            verb=_('created new repo'),
+                            target=repo_model,
+                            public=True,
+                            level='info',
+                            actions=[
+                                {
+                                    'title': _('View repository'),
+                                    'href': repo_model.get_absolute_url(),
+                                },
+                            ],
+                        )
+                    # Redirect to repo view
+                    return shortcuts.redirect(
+                        urlresolvers.reverse('tools:repo_view', kwargs={
+                            'tool': tool.name,
+                            'repo': name,
+                        }))
+                except DatabaseError:
+                    logger.exception('repo_model.save failed')
+                    messages.error(
+                        req,
+                        _("Error updating database. [req id: {id}]").format(
+                            id=req.id))
+
+    ctx = {
+        'tool': tool,
+        'form': form,
+    }
+    return shortcuts.render(req, 'tools/repo/create.html', ctx)
+
+
+@inject_tool
+def view(req, tool, repo):
+    ctx = {
+        'tool': tool,
+        'repo': repo,
+        'repo_id': None,
+        'status': 'unknown',
+        'urls': [],
+        'policy': {'view': None, 'edit': None, 'push': None},
+        'phab_url': settings.PHABRICATOR_URL,
+    }
+    try:
+        repository = phab.get_repository(repo)
+        ctx['repo_id'] = repository['id']
+        ctx['status'] = repository['fields']['status']
+        ctx['urls'] = [
+            u['fields']['uri']['display'] for u in
+            repository['attachments']['uris']['uris']
+            if u['fields']['display']['effective'] == 'always'
+        ]
+
+        # Lookup policy details
+        policy = repository['fields']['policy']
+        policies = phab.get_policies(list(set(policy.values())))
+        ctx['policy']['view'] = policies[policy['view']]
+        ctx['policy']['edit'] = policies[policy['edit']]
+        ctx['policy']['push'] = policies[policy['diffusion.push']]
+
+        # Lookup phid details for custom rules
+        phids = []
+        for p in policies.values():
+            if p['type'] == 'custom':
+                for r in p['rules']:
+                    phids.extend(r['value'])
+        ctx['phids'] = phab.get_phids(list(set(phids)))
+    except KeyError:
+        pass
+    except phabricator.APIError as e:
+        logger.error(e)
+
+    return shortcuts.render(req, 'tools/repo.html', ctx)
diff --git a/striker/tools/views/tool.py b/striker/tools/views/tool.py
new file mode 100644
index 0000000..2149a39
--- /dev/null
+++ b/striker/tools/views/tool.py
@@ -0,0 +1,269 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2016 Wikimedia Foundation and contributors.
+# All Rights Reserved.
+#
+# This file is part of Striker.
+#
+# Striker is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Striker is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Striker.  If not, see <http://www.gnu.org/licenses/>.
+
+import itertools
+import logging
+
+from django import shortcuts
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.models import Group
+from django.core.exceptions import ObjectDoesNotExist
+from django.http import JsonResponse
+from django.utils.translation import ugettext_lazy as _
+from django.views.decorators.cache import never_cache
+
+from dal import autocomplete
+from notifications.signals import notify
+import reversion
+import reversion.models
+
+from striker.labsauth.models import LabsUser
+from striker.tools import utils
+from striker.tools.forms import MantainersForm
+from striker.tools.forms import ToolCreateForm
+from striker.tools.models import DiffusionRepo
+from striker.tools.models import Maintainer
+from striker.tools.models import ToolInfo
+from striker.tools.models import ToolUser
+from striker.tools.utils import member_or_admin
+from striker.tools.views.decorators import inject_tool
+from striker.tools.views.decorators import require_tools_member
+
+
+logger = logging.getLogger(__name__)
+
+
+@inject_tool
+def view(req, tool):
+    return shortcuts.render(req, 'tools/tool.html', {
+        'tool': tool,
+        'toolinfo': tool.toolinfo(),
+        'repos': DiffusionRepo.objects.filter(tool=tool.name),
+        'can_edit': member_or_admin(tool, req.user),
+    })
+
+
+@require_tools_member
+@login_required
+def create(req):
+    form = ToolCreateForm(req.POST or None, req.FILES or None)
+    if req.method == 'POST':
+        if form.is_valid():
+            try:
+                tool = utils.create_tool(form.cleaned_data['name'], req.user)
+            except Exception:
+                logger.exception('utils.create_tool failed')
+                messages.error(
+                    req,
+                    _("Error creating tool. [req id: {id}]").format(
+                        id=req.id))
+            else:
+                messages.info(
+                    req, _("Tool {} created".format(tool.name)))
+                try:
+                    with reversion.create_revision():
+                        toolinfo = ToolInfo(
+                            name='toolforge.{}'.format(
+                                form.cleaned_data['name']),
+                            tool=form.cleaned_data['name'],
+                            title=form.cleaned_data['title'],
+                            description=form.cleaned_data['description'],
+                            license=form.cleaned_data['license'],
+                            is_webservice=False,
+                        )
+                        reversion.set_user(req.user)
+                        reversion.set_comment('Tool created')
+                        toolinfo.save()
+                        toolinfo.authors.add(req.user)
+                        if form.cleaned_data['tags']:
+                            toolinfo.tags.add(*form.cleaned_data['tags'])
+                except Exception:
+                    logger.exception('ToolInfo create failed')
+                    messages.error(
+                        req,
+                        _("Error creating toolinfo. [req id: {id}]").format(
+                            id=req.id))
+                try:
+                    maintainers = Group.objects.get(name=tool.cn)
+                except ObjectDoesNotExist:
+                    # Can't find group for the tool
+                    pass
+                else:
+                    # Do not set tool as the notification target because the
+                    # framework does not understand LDAP models.
+                    notify.send(
+                        recipient=maintainers,
+                        sender=req.user,
+                        verb=_('created tool {}').format(tool.name),
+                        public=True,
+                        level='info',
+                        actions=[
+                            {
+                                'title': _('View tool'),
+                                'href': tool.get_absolute_url(),
+                            },
+                        ],
+                    )
+
+                return shortcuts.redirect(tool.get_absolute_url())
+    ctx = {
+        'form': form,
+    }
+    return shortcuts.render(req, 'tools/create.html', ctx)
+
+
+@never_cache
+def toolname_available(req, name):
+    """JSON callback for parsley validation of tool name.
+
+    Kind of gross, but it returns a 406 status code when the name is not
+    available. This is to work with the limited choice of default response
+    validators in parsley.
+    """
+    available = utils.toolname_available(name)
+    if available:
+        available = utils.check_toolname_create(name)['ok']
+    status = 200 if available else 406
+    return JsonResponse({
+        'available': available,
+    }, status=status)
+
+
+@login_required
+@inject_tool
+def maintainers(req, tool):
+    """Manage the maintainers list for a tool"""
+    if not member_or_admin(tool, req.user):
+        messages.error(
+            req, _('You are not a member of {tool}').format(tool=tool.name))
+        return shortcuts.redirect(tool.get_absolute_url())
+    form = MantainersForm(req.POST or None, req.FILES or None, tool=tool)
+    if req.method == 'POST':
+        if form.is_valid():
+            old_members = set(tool.members)
+            new_members = set(
+                m.dn for m in itertools.chain(
+                    form.cleaned_data['maintainers'],
+                    form.cleaned_data['tools']
+                )
+            )
+            tool.members = new_members
+            tool.save()
+
+            maintainers, created = Group.objects.get_or_create(name=tool.cn)
+            added_maintainers = list(new_members - old_members)
+            removed_maintainers = list(old_members - new_members)
+            for dn in added_maintainers:
+                uid = dn.split(',')[0][4:]
+                logger.info('Added %s', uid)
+                try:
+                    added = LabsUser.objects.get(shellname=uid)
+                except ObjectDoesNotExist:
+                    # No local user for this account
+                    logger.info('No LabsUser found for %s', uid)
+                    pass
+                else:
+                    # Add user to the mirrored group
+                    added.groups.add(maintainers.id)
+                    # Do not set tool as the notification target because the
+                    # framework does not understand LDAP models.
+                    notify.send(
+                        recipient=added,
+                        sender=req.user,
+                        verb=_(
+                            'added you as a maintainer of {}'
+                        ).format(tool.name),
+                        public=True,
+                        level='info',
+                        actions=[
+                            {
+                                'title': _('View tool'),
+                                'href': tool.get_absolute_url(),
+                            },
+                        ],
+                    )
+
+            for dn in removed_maintainers:
+                uid = dn.split(',')[0][4:]
+                logger.info('Removed %s', uid)
+                try:
+                    removed = LabsUser.objects.get(shellname=uid)
+                except ObjectDoesNotExist:
+                    # No local user for this account
+                    logger.info('No LabsUser found for %s', uid)
+                    pass
+                else:
+                    # Add user to the mirrored group
+                    removed.groups.remove(maintainers.id)
+                    # Do not set tool as the notification target because the
+                    # framework does not understand LDAP models.
+                    notify.send(
+                        recipient=removed,
+                        sender=req.user,
+                        verb=_(
+                            'removed you from the maintainers of {}'
+                        ).format(tool.name),
+                        public=True,
+                        level='info',
+                        actions=[
+                            {
+                                'title': _('View tool'),
+                                'href': tool.get_absolute_url(),
+                            },
+                        ],
+                    )
+
+            messages.info(req, _('Maintainers updated'))
+            return shortcuts.redirect(tool.get_absolute_url())
+
+    ctx = {
+        'tool': tool,
+        'form': form,
+    }
+    return shortcuts.render(req, 'tools/maintainers.html', ctx)
+
+
+class MaintainerAutocomplete(autocomplete.Select2QuerySetView):
+    def get_queryset(self):
+        if not self.request.user.is_authenticated():
+            return Maintainer.objects.none()
+        qs = Maintainer.objects.all()
+        if self.q:
+            qs = qs.filter(cn__icontains=self.q)
+        qs.order_by('cn')
+        return qs
+
+    def get_result_label(self, result):
+        return result.cn
+
+
+class ToolUserAutocomplete(autocomplete.Select2QuerySetView):
+    def get_queryset(self):
+        if not self.request.user.is_authenticated():
+            return ToolUser.objects.none()
+        qs = ToolUser.objects.all()
+        if self.q:
+            qs = qs.filter(uid__icontains=self.q)
+        qs.order_by('uid')
+        return qs
+
+    def get_result_label(self, result):
+        return result.name
diff --git a/striker/tools/views/toolinfo.py b/striker/tools/views/toolinfo.py
new file mode 100644
index 0000000..0fc0523
--- /dev/null
+++ b/striker/tools/views/toolinfo.py
@@ -0,0 +1,320 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2016 Wikimedia Foundation and contributors.
+# All Rights Reserved.
+#
+# This file is part of Striker.
+#
+# Striker is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Striker is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Striker.  If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+
+from django import shortcuts
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.core import urlresolvers
+from django.core.serializers.json import DjangoJSONEncoder
+from django.db import transaction
+from django.db.utils import DatabaseError
+from django.http import JsonResponse
+from django.utils.text import slugify
+from django.utils.translation import ugettext_lazy as _
+
+from dal import autocomplete
+import reversion
+import reversion.models
+import reversion_compare.views
+
+from striker.tools.forms import ToolInfoForm
+from striker.tools.forms import ToolInfoPublicForm
+from striker.tools.models import Tool
+from striker.tools.models import ToolInfo
+from striker.tools.models import ToolInfoTag
+from striker.tools.utils import member_or_admin
+from striker.tools.views.decorators import inject_tool
+
+
+logger = logging.getLogger(__name__)
+
+
[email protected]_revision()
+@login_required
+@inject_tool
+def create(req, tool):
+    """Create a ToolInfo record."""
+    if not member_or_admin(tool, req.user):
+        messages.error(
+            req, _('You are not a member of {tool}').format(tool=tool.name))
+        return shortcuts.redirect(tool.get_absolute_url())
+
+    initial_values = {
+        'name': tool.name,
+        'author': req.user,
+    }
+    if ToolInfo.objects.filter(tool=tool.name).count():
+        initial_values['name'] = '{}-'.format(tool.name)
+    form = ToolInfoForm(
+        req.POST or None, req.FILES or None, initial=initial_values)
+    if req.method == 'POST':
+        if form.is_valid():
+            try:
+                reversion.set_comment(form.cleaned_data['comment'])
+                toolinfo = form.save(commit=False)
+                toolinfo.tool = tool.name
+                toolinfo.save()
+                form.save_m2m()
+                reversion.add_to_revision(toolinfo)
+                messages.info(
+                    req, _("Toolinfo {} created".format(toolinfo.title)))
+                return shortcuts.redirect(
+                    urlresolvers.reverse('tools:tool', kwargs={
+                        'tool': tool.name,
+                    }))
+            except DatabaseError:
+                logger.exception('ToolInfo.save failed')
+                messages.error(
+                    req,
+                    _("Error updating database. [req id: {id}]").format(
+                        id=req.id))
+    ctx = {
+        'tool': tool,
+        'form': form,
+    }
+    return shortcuts.render(req, 'tools/info/create.html', ctx)
+
+
+@inject_tool
+def read(req, tool, info_id):
+    """View a ToolInfo record."""
+    toolinfo = shortcuts.get_object_or_404(ToolInfo, pk=info_id, tool=tool)
+    ctx = {
+        'tool': tool,
+        'toolinfo': toolinfo,
+    }
+    return shortcuts.render(req, 'tools/info/read.html', ctx)
+
+
[email protected]_revision()
+@login_required
+@inject_tool
+def edit(req, tool, info_id):
+    """Create a ToolInfo record."""
+    toolinfo = shortcuts.get_object_or_404(ToolInfo, pk=info_id, tool=tool)
+    if member_or_admin(tool, req.user):
+        form = ToolInfoForm(
+            req.POST or None, req.FILES or None, instance=toolinfo)
+    else:
+        form = ToolInfoPublicForm(
+            req.POST or None, req.FILES or None, instance=toolinfo)
+
+    if req.method == 'POST':
+        if form.is_valid():
+            try:
+                reversion.set_comment(form.cleaned_data['comment'])
+                toolinfo = form.save()
+                reversion.add_to_revision(toolinfo)
+                messages.info(
+                    req, _("Toolinfo {} updated".format(toolinfo.title)))
+                return shortcuts.redirect(
+                    urlresolvers.reverse('tools:tool', kwargs={
+                        'tool': tool.name,
+                    }))
+            except DatabaseError:
+                logger.exception('ToolInfo.save failed')
+                messages.error(
+                    req,
+                    _("Error updating database. [req id: {id}]").format(
+                        id=req.id))
+    ctx = {
+        'tool': tool,
+        'toolinfo': toolinfo,
+        'form': form,
+    }
+    return shortcuts.render(req, 'tools/info/update.html', ctx)
+
+
+class HistoryView(reversion_compare.views.HistoryCompareDetailView):
+    model = ToolInfo
+    pk_url_kwarg = 'info_id'
+    template_name = 'tools/info/history.html'
+
+    def get_queryset(self):
+        qs = super(HistoryView, self).get_queryset()
+        return qs.filter(tool=self.kwargs['tool'])
+
+    def _get_action_list(self):
+        actions = super(HistoryView, self)._get_action_list()
+        for action in actions:
+            action['url'] = urlresolvers.reverse(
+                'tools:info_revision',
+                kwargs={
+                    'tool': self.kwargs['tool'],
+                    'info_id': self.kwargs['info_id'],
+                    'version_id': action['version'].pk,
+                },
+            )
+        return actions
+
+    def get_context_data(self, **kwargs):
+        user = self.request.user
+        tool = Tool.objects.get(cn='tools.{0}'.format(kwargs['object'].tool))
+
+        ctx = super(HistoryView, self).get_context_data(**kwargs)
+        ctx['toolinfo'] = kwargs['object']
+        ctx['tool'] = tool
+        ctx['show_suppressed'] = member_or_admin(tool, user)
+        return ctx
+
+
+@inject_tool
+def revision(req, tool, info_id, version_id):
+    """View/revert/suppress a particular version of a ToolInfo model."""
+    tool = shortcuts.get_object_or_404(Tool, cn='tools.{}'.format(tool))
+    toolinfo = shortcuts.get_object_or_404(ToolInfo, pk=info_id, tool=tool)
+    version = shortcuts.get_object_or_404(
+        reversion.models.Version, pk=version_id, object_id=info_id)
+
+    can_revert = member_or_admin(tool, req.user)
+    can_suppress = member_or_admin(tool, req.user)
+
+    history_url = urlresolvers.reverse(
+        'tools:info_history',
+        kwargs={
+            'tool': tool.name,
+            'info_id': info_id,
+        })
+
+    if req.method == 'POST' and (
+            '_hide' in req.POST or
+            '_show' in req.POST
+    ):
+        if can_suppress:
+            try:
+                version.suppressed = '_hide' in req.POST
+                version.save()
+                if version.suppressed:
+                    msg = _("Revision {id} hidden")
+                else:
+                    msg = _("Revision {id} shown")
+                messages.info(req, msg.format(id=version_id))
+            except DatabaseError:
+                logger.exception('Revision.suppress failed')
+                messages.error(
+                    req,
+                    _("Error updating database. [req id: {id}]").format(
+                        id=req.id))
+        else:
+            messages.error(req, _("Tool membership required"))
+        return shortcuts.redirect(history_url)
+
+    try:
+        # This try/except block is pretty gross, but its the way that
+        # django-reversion provides to get a historic model. We start a db
+        # transaction, revert the revision, grab the model from the db, render
+        # it to a response, and then wrap that rendered response in an
+        # exception. We raise the exception to trigger a rollback of the
+        # transaction (gross), and then catch the exception and return the
+        # wrapped response.
+        with transaction.atomic(using=version.db):
+            version.revision.revert()
+            # Fetch the toolinfo again now that it hav been reverted
+            toolinfo = shortcuts.get_object_or_404(
+                ToolInfo, pk=info_id, tool=tool.name)
+
+            if req.method == 'POST':
+                if '_revert' in req.POST:
+                    if not can_revert:
+                        messages.error(req, _("Tool membership required"))
+                        raise reversion.views._RollBackRevisionView(None)
+                    try:
+                        with reversion.create_revision():
+                            dt = version.revision.date_created.strftime(
+                                '%Y-%m-%dT%H:%M:%S%z')
+                            reversion.set_user(req.user)
+                            reversion.set_comment(
+                                '{} reverted to version saved on {}'.format(
+                                    req.user,
+                                    dt))
+                            toolinfo.save()
+                            messages.info(
+                                req,
+                                _("Toolinfo {} reverted to {}".format(
+                                    toolinfo.title,
+                                    dt)))
+                            # Return instead of raise so transactions are
+                            # committed
+                            return shortcuts.redirect(history_url)
+                    except DatabaseError:
+                        logger.exception('ToolInfo.revert failed')
+                        messages.error(
+                            req,
+                            _(
+                                "Error updating database. [req id: {id}]"
+                            ).format(id=req.id))
+                        raise reversion.views._RollBackRevisionView(None)
+
+            ctx = {
+                'tool': tool,
+                'toolinfo': toolinfo,
+                'version': version,
+                'can_revert': can_revert,
+                'can_suppress': can_suppress,
+            }
+            resp = shortcuts.render(req, 'tools/info/revision.html', ctx)
+            raise reversion.views._RollBackRevisionView(resp)
+    except reversion.errors.RevertError:
+        logger.exception('ToolInfo.revert failed')
+        return shortcuts.redirect(history_url)
+
+    except reversion.views._RollBackRevisionView as ex:
+        if ex.response:
+            return ex.response
+        else:
+            return shortcuts.redirect(history_url)
+
+
+class TagAutocomplete(autocomplete.Select2QuerySetView):
+    create_field = 'name'
+
+    def get_queryset(self):
+        if not self.request.user.is_authenticated():
+            return ToolInfoTag.objects.none()
+        qs = ToolInfoTag.objects.all()
+        if self.q:
+            qs = qs.filter(name__icontains=self.q)
+        qs.order_by('name')
+        return qs
+
+    def has_add_permission(self, request):
+        return request.user.is_authenticated()
+
+    def create_object(self, text):
+        return ToolInfoTag.objects.create(name=text, slug=slugify(text))
+
+
+def json_v1(req):
+    class PrettyPrintJSONEncoder(DjangoJSONEncoder):
+        def __init__(self, *args, **kwargs):
+            kwargs['indent'] = 2
+            kwargs['separators'] = (',', ':')
+            super(PrettyPrintJSONEncoder, self).__init__(*args, **kwargs)
+
+    return JsonResponse(
+        [
+            info.toolinfo()
+            for info in ToolInfo.objects.all().order_by('name')
+        ],
+        encoder=PrettyPrintJSONEncoder,
+        safe=False,
+    )

-- 
To view, visit https://gerrit.wikimedia.org/r/364136
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: If9d20212f1dac651968b1f645a636c13e4294c56
Gerrit-PatchSet: 1
Gerrit-Project: labs/striker
Gerrit-Branch: master
Gerrit-Owner: BryanDavis <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to