changeset 436:cfbc9ee622d5

Finish the move of build configuration management into the admin interface.
author cmlenz
date Fri, 17 Aug 2007 10:43:09 +0000
parents 8424a8afd1a1
children 6d5ac24061dc
files bitten/admin.py bitten/build/ctools.py bitten/build/tests/config.py bitten/build/xmltools.py bitten/htdocs/admin.css bitten/master.py bitten/model.py bitten/slave.py bitten/templates/bitten_admin_configs.cs bitten/tests/admin.py bitten/tests/master.py bitten/tests/queue.py bitten/tests/slave.py bitten/tests/web_ui.py bitten/web_ui.py doc/install.txt
diffstat 16 files changed, 545 insertions(+), 880 deletions(-) [+]
line wrap: on
line diff
--- a/bitten/admin.py
+++ b/bitten/admin.py
@@ -13,6 +13,7 @@
 import re
 
 from trac.core import *
+from trac.web.chrome import add_stylesheet
 try:
     require("TracWebAdmin")
     from webadmin.web_ui import IAdminPageProvider
@@ -20,7 +21,7 @@
     IAdminPageProvider = None
 
 from bitten.model import BuildConfig, TargetPlatform
-from bitten.recipe import Recipe
+from bitten.recipe import Recipe, InvalidRecipeError
 from bitten.util import xmlio
 
 
@@ -48,6 +49,7 @@
             'adjust_timestamps': master.adjust_timestamps,
             'slave_timeout': master.slave_timeout,
         }
+        add_stylesheet(req, 'bitten/admin.css')
         return 'bitten_admin_master.cs', None
 
     # Internal methods
@@ -93,17 +95,29 @@
         data = {}
 
         if config_name:
-            if '/' in config_name:
-                config_name, platform_id = config_name.split('/', 1)
-                platform_id = int(platform_id)
+            if '/' in config_name or (
+                    req.method == 'POST' and 'new' in req.args):
+                if '/' in config_name:
+                    config_name, platform_id = config_name.split('/', 1)
+                    platform_id = int(platform_id)
+                    platform = TargetPlatform.fetch(self.env, platform_id)
 
-                platform = TargetPlatform.fetch(self.env, platform_id)
+                    if req.method == 'POST':
+                        if 'cancel' in req.args or \
+                                self._update_platform(req, platform):
+                            req.redirect(req.abs_href.admin(cat, page,
+                                                            config_name))
+                else:
+                    if req.method == 'POST':
+                        if 'add' in req.args:
+                            self._create_platform(req, config_name)
+                            req.redirect(req.abs_href.admin(cat, page,
+                                                            config_name))
+                        elif 'cancel' in req.args:
+                            req.redirect(req.abs_href.admin(cat, page,
+                                                            config_name))
 
-                if req.method == 'POST':
-                    if 'cancel' in req.args:
-                        req.redirect(req.abs_href.admin(cat, page, config_name))
-                    elif self._process_platform(req, platform):
-                        req.redirect(req.abs_href.admin(cat, page, config_name))
+                    platform = TargetPlatform(self.env, config=config_name)
 
                 data['platform'] = {
                     'id': platform.id, 'name': platform.name,
@@ -120,7 +134,13 @@
                                                        config=config.name))
 
                 if req.method == 'POST':
-                    if 'save' in req.args:
+                    if 'add' in req.args: # Add target platform
+                        platform = self._create_platform(req, config)
+                        req.redirect(req.abs_href.admin(cat, page, config.name))
+                    elif 'remove' in req.args: # Remove selected platforms
+                        self._remove_platforms(req)
+                        req.redirect(req.abs_href.admin(cat, page, config.name))
+                    elif 'save' in req.args:
                         self._update_config(req, config)
                     req.redirect(req.abs_href.admin(cat, page))
 
@@ -134,7 +154,9 @@
                         'name': platform.name,
                         'id': platform.id,
                         'href': req.href.admin('bitten', 'configs', config.name,
-                                               platform.id)
+                                               platform.id),
+                        'rules': [{'property': propname, 'pattern': pattern}
+                                   for propname, pattern in platform.rules]
                     } for platform in platforms]
                 }
 
@@ -160,6 +182,7 @@
             data['configs'] = configs
 
         req.hdf['admin'] = data
+        add_stylesheet(req, 'bitten/admin.css')
         return 'bitten_admin_configs.cs', None
 
     # Internal methods
@@ -173,8 +196,8 @@
 
         active = isinstance(active, list) and active or [active]
         db = self.env.get_db_cnx()
-        for config in BuildConfig.select(self.env, db=db,
-                                         include_inactive=True):
+        for config in list(BuildConfig.select(self.env, db=db,
+                                              include_inactive=True)):
             config.active = config.name in active
             config.update(db=db)
         db.commit()
@@ -182,18 +205,8 @@
     def _create_config(self, req):
         req.perm.assert_permission('BUILD_CREATE')
 
-        name = req.args.get('name')
-        if not name:
-            raise TracError('Missing required field "name"', 'Missing field')
-        if not re.match(r'^[\w.-]+$', name):
-            raise TracError('The field "name" may only contain letters, '
-                            'digits, periods, or dashes.', 'Invalid field')
-
         config = BuildConfig(self.env)
-        config.name = req.args.get('name')
-        config.label = req.args.get('label', config.name)
-        config.path = req.args.get('path')
-        config.insert()
+        self._update_config(req, config)
         return config
 
     def _remove_configs(self, req):
@@ -217,10 +230,10 @@
 
         name = req.args.get('name')
         if not name:
-            raise TracError('Missing required field "name"', 'Missing field')
+            raise TracError('Missing required field "name"', 'Missing Field')
         if not re.match(r'^[\w.-]+$', name):
             raise TracError('The field "name" may only contain letters, '
-                            'digits, periods, or dashes.', 'Invalid field')
+                            'digits, periods, or dashes.', 'Invalid Field')
 
         path = req.args.get('path', '')
         repos = self.env.get_repository(req.authname)
@@ -229,12 +242,12 @@
             node = repos.get_node(path, max_rev)
             assert node.isdir, '%s is not a directory' % node.path
         except (AssertionError, TracError), e:
-            raise TracError(e, 'Invalid repository path')
+            raise TracError(unicode(e), 'Invalid Repository Path')
         if req.args.get('min_rev'):
             try:
                 repos.get_node(path, req.args.get('min_rev'))
             except TracError, e:
-                raise TracError(e, 'Invalid value for oldest revision')
+                raise TracError(unicode(e), 'Invalid Oldest Revision')
 
         recipe_xml = req.args.get('recipe', '')
         if recipe_xml:
@@ -242,9 +255,9 @@
                 Recipe(xmlio.parse(recipe_xml)).validate()
             except xmlio.ParseError, e:
                 raise TracError('Failure parsing recipe: %s' % e,
-                                'Invalid recipe')
+                                'Invalid Recipe')
             except InvalidRecipeError, e:
-                raise TracError(e, 'Invalid recipe')
+                raise TracError(unicode(e), 'Invalid Recipe')
 
         config.name = name
         config.path = repos.normalize_path(path)
@@ -253,9 +266,40 @@
         config.max_rev = req.args.get('max_rev')
         config.label = req.args.get('label', config.name)
         config.description = req.args.get('description', '')
-        config.update()
 
-    def _process_platform(self, req, platform):
+        if config.exists:
+            config.update()
+        else:
+            config.insert()
+
+    def _create_platform(self, req, config_name):
+        req.perm.assert_permission('BUILD_MODIFY')
+
+        name = req.args.get('name')
+        if not name:
+            raise TracError('Missing required field "name"', 'Missing field')
+
+        platform = TargetPlatform(self.env, config=config_name, name=name)
+        self._update_platform(req, platform)
+        return platform
+
+    def _remove_platforms(self, req):
+        req.perm.assert_permission('BUILD_MODIFY')
+
+        sel = req.args.get('sel')
+        if not sel:
+            raise TracError('No platform selected')
+        sel = isinstance(sel, list) and sel or [sel]
+
+        db = self.env.get_db_cnx()
+        for platform_id in sel:
+            platform = TargetPlatform.fetch(self.env, platform_id, db=db)
+            if not platform:
+                raise TracError('Target platform %r not found' % platform_id)
+            platform.delete(db=db)
+        db.commit()
+
+    def _update_platform(self, req, platform):
         platform.name = req.args.get('name')
 
         properties = [int(key[9:]) for key in req.args.keys()
@@ -269,6 +313,11 @@
                           for property, pattern in zip(properties, patterns)
                           if req.args.get('property_%d' % property)]
 
+        if platform.exists:
+            platform.update()
+        else:
+            platform.insert()
+
         add_rules = [int(key[9:]) for key in req.args.keys()
                      if key.startswith('add_rule_')]
         if add_rules:
@@ -277,7 +326,8 @@
         rm_rules = [int(key[8:]) for key in req.args.keys()
                     if key.startswith('rm_rule_')]
         if rm_rules:
-            del platform.rules[rm_rules[0]]
+            if rm_rules[0] < len(platform.rules):
+                del platform.rules[rm_rules[0]]
             return False
 
         return True
--- a/bitten/build/ctools.py
+++ b/bitten/build/ctools.py
@@ -13,10 +13,7 @@
 import logging
 import re
 import os
-try:
-    set
-except NameError:
-    from sets import Set as set
+import posixpath
 
 from bitten.build import CommandLine, FileSet
 from bitten.util import xmlio
--- a/bitten/build/tests/config.py
+++ b/bitten/build/tests/config.py
@@ -10,7 +10,6 @@
 
 import platform
 import os
-import shutil
 import tempfile
 import unittest
 
--- a/bitten/build/xmltools.py
+++ b/bitten/build/xmltools.py
@@ -13,9 +13,6 @@
 import logging
 import os
 
-from bitten.build import CommandLine
-from bitten.util import xmlio
-
 try:
     import libxml2
     import libxslt
new file mode 100644
--- /dev/null
+++ b/bitten/htdocs/admin.css
@@ -0,0 +1,3 @@
+table.form th { text-align: right; }
+div.platforms h3 { margin-top: 3em; }
+table#platformlist td ul { list-style: none; margin: 0; padding: 0; }
--- a/bitten/master.py
+++ b/bitten/master.py
@@ -11,20 +11,11 @@
 """Build master implementation."""
 
 import calendar
-from datetime import datetime, timedelta
-import logging
-import os
 import re
-try:
-    set
-except NameError:
-    from sets import Set as set
-import sys
 import time
 
 from trac.config import BoolOption, IntOption
 from trac.core import *
-from trac.env import Environment
 from trac.web import IRequestHandler, HTTPBadRequest, HTTPConflict, \
                      HTTPForbidden, HTTPMethodNotAllowed, HTTPNotFound, \
                      RequestDone
--- a/bitten/model.py
+++ b/bitten/model.py
@@ -10,11 +10,6 @@
 
 """Model classes for objects persisted in the database."""
 
-try:
-    set
-except NameError:
-    from sets import Set as set
-
 from trac.db import Table, Column, Index
 
 __docformat__ = 'restructuredtext en'
--- a/bitten/slave.py
+++ b/bitten/slave.py
@@ -15,18 +15,13 @@
 import logging
 import os
 import platform
-try:
-    set
-except NameError:
-    from sets import Set as set
 import shutil
 import tempfile
 import time
-import urlparse
 
 from bitten.build import BuildError
 from bitten.build.config import Configuration
-from bitten.recipe import Recipe, InvalidRecipeError
+from bitten.recipe import Recipe
 from bitten.util import xmlio
 
 __all__ = ['BuildSlave', 'ExitSlave']
--- a/bitten/templates/bitten_admin_configs.cs
+++ b/bitten/templates/bitten_admin_configs.cs
@@ -2,7 +2,7 @@
 
 if admin.config.name ?>
  <form class="mod" id="modconfig" method="post">
-  <table summary=""><tr>
+  <table class="form" summary=""><tr>
    <td class="name"><label>Name:<br />
     <input type="text" name="name" value="<?cs var:admin.config.name ?>" />
    </label></td>
@@ -14,20 +14,21 @@
    <td colspan="2"><fieldset class="iefix">
     <label for="description">Description (you may use <a tabindex="42" href="<?cs
       var:trac.href.wiki ?>/WikiFormatting">WikiFormatting</a> here):</label>
-    <p><textarea id="description" name="description" class="wikitext" rows="5" cols="78"><?cs
+    <p><textarea id="description" name="description" class="wikitext" rows="3" cols="65"><?cs
       var:admin.config.description ?></textarea></p>
     <script type="text/javascript" src="<?cs
       var:chrome.href ?>/common/js/wikitoolbar.js"></script>
    </fieldset></td>
+  </tr><tr>
+   <td colspan="2"><fieldset class="iefix">
+    <label for="recipe">Recipe</label>
+    <p><textarea id="recipe" name="recipe" rows="8" cols="78"><?cs
+     var:admin.config.recipe ?></textarea></p>
+   </fieldset></td>
   </tr></table>
-  <fieldset id="recipe">
-   <legend>Build Recipe</legend>
-   <textarea id="recipe" name="recipe" rows="8" cols="78"><?cs
-     var:admin.config.recipe ?></textarea>
-  </fieldset>
   <fieldset id="repos">
    <legend>Repository Mapping</legend>
-   <table summary=""><tr>
+   <table class="form" summary=""><tr>
     <th><label for="path">Path:</label></th>
     <td colspan="3"><input type="text" name="path" size="48" value="<?cs
       var:admin.config.path ?>" /></td>
@@ -40,61 +41,72 @@
       var:admin.config.max_rev ?>" /></td>
    </table>
   </fieldset>
-  <fieldset>
-    <legend>Target Platforms</legend><?cs
-    if:len(admin.config.platforms) ?><ul><?cs
-     each:platform = admin.config.platforms ?>
-      <li><input type="checkbox" name="delete_platform" value="<?cs
-       var:platform.id ?>"> <a href="<?cs
-       var:platform.href ?>"><?cs var:platform.name ?></a>
-      </li><?cs
-     /each ?></ul><?cs
-    /if ?>
-    <div class="buttons">
-     <input type="submit" name="new" value="Add target platform" />
-     <input type="submit" name="delete" value="Delete selected platforms" />
-    </div>
-  </fieldset>
   <div class="buttons">
    <input type="submit" name="cancel" value="Cancel" />
    <input type="submit" name="save" value="Save" />
   </div>
+  <div class="platforms">
+   <h3>Target Platforms</h3>
+   <table class="listing" id="platformlist">
+    <thead>
+     <tr><th class="sel">&nbsp;</th><th>Name</th><th>Rules</th></tr>
+    </thead><?cs each:platform = admin.config.platforms ?><tr>
+      <td class="sel"><input type="checkbox" name="sel" value="<?cs
+        var:platform.id ?>" /></td>
+      <td class="name"><a href="<?cs var:platform.href?>"><?cs
+        var:platform.name ?></a></td>
+      <td class="rules"><?cs if:len(platform.rules) ?><ul><?cs
+       each:rule = platform.rules ?><li><code>
+        <strong><?cs var:rule.property ?></strong> ~= <?cs var:rule.pattern ?>
+       </code></li><?cs
+       /each ?></ul><?cs
+      /if ?></td>
+     </tr><?cs
+    /each ?>
+   </table>
+   <div class="buttons">
+    <input type="submit" name="new" value="Add platform" />
+    <input type="submit" name="remove" value="Delete selected platforms" />
+   </div>
+  </div>
  </form><?cs
 
-elif admin.platform.name ?>
+elif len(admin.platform) ?>
  <form class="mod" id="modplatform" method="post">
-    <div class="field"><label>Target Platform:
-     <input type="text" name="name" value="<?cs var:admin.platform.name ?>" />
-    </label></div>
-    <fieldset>
-     <legend>Rules</legend>
-     <table><thead><tr>
-      <th>Property name</th><th>Match pattern</th>
-     </tr></thead><tbody><?cs
-      each:rule = admin.platform.rules ?><tr>
-       <td><input type="text" name="property_<?cs var:name(rule) ?>" value="<?cs
-        var:rule.property ?>" /></td>
-       <td><input type="text" name="pattern_<?cs var:name(rule) ?>" value="<?cs
-        var:rule.pattern ?>" /></td>
-       <td><input type="submit" name="add_rule_<?cs
-         var:name(rule) ?>" value="+" /><input type="submit" name="rm_rule_<?cs
-         var:name(rule) ?>" value="-" />
-       </td>
-      </tr><?cs /each ?>
-     </tbody></table>
-    </fieldset>
-    <div class="buttons">
-     <form method="get" action=""><div>
-      <input type="hidden" name="action" value="<?cs
-       if:admin.platform.exists ?>edit<?cs else ?>new<?cs /if ?>" />
-      <input type="hidden" name="platform" value="<?cs
-       var:admin.platform.id ?>" />
-      <input type="submit" name="cancel" value="Cancel" />
-      <input type="submit" name="save" value="<?cs
-       if:admin.platform.exists ?>Save<?cs else ?>Add<?cs
-       /if ?>" />
-     </div></form>
-    </div>
+  <div class="field"><label>Target Platform:
+   <input type="text" name="name" value="<?cs var:admin.platform.name ?>" />
+  </label></div>
+  <fieldset>
+   <legend>Rules</legend>
+   <table><thead><tr>
+    <th>Property name</th><th>Match pattern</th>
+   </tr></thead><tbody><?cs
+    each:rule = admin.platform.rules ?><tr>
+     <td><input type="text" name="property_<?cs var:name(rule) ?>" value="<?cs
+      var:rule.property ?>" /></td>
+     <td><input type="text" name="pattern_<?cs var:name(rule) ?>" value="<?cs
+      var:rule.pattern ?>" /></td>
+     <td><input type="submit" name="add_rule_<?cs
+       var:name(rule) ?>" value="+" /><input type="submit" name="rm_rule_<?cs
+       var:name(rule) ?>" value="-" />
+     </td>
+    </tr><?cs /each ?>
+   </tbody></table>
+  </fieldset>
+  <div class="buttons">
+   <form method="get" action=""><div>
+    <input type="hidden" name="<?cs
+     if:admin.platform.exists ?>edit<?cs else ?>new<?cs /if ?>" value="" />
+    <input type="hidden" name="platform" value="<?cs
+     var:admin.platform.id ?>" />
+    <input type="submit" name="cancel" value="Cancel" />
+    <?cs if:admin.platform.exists ?>
+     <input type="submit" name="save" value="Save" />
+    <?cs else ?>
+     <input type="submit" name="add" value="Add" />
+    <?cs /if ?>
+   </div></form>
+  </div>
  </form><?cs
 
 else ?>
--- a/bitten/tests/admin.py
+++ b/bitten/tests/admin.py
@@ -15,12 +15,9 @@
 from trac.db import DatabaseManager
 from trac.perm import PermissionCache, PermissionError, PermissionSystem
 from trac.test import EnvironmentStub, Mock
-from trac.versioncontrol import Repository
-from trac.web.clearsilver import HDFWrapper
 from trac.web.href import Href
-from trac.web.main import Request, RequestDone
-from bitten.main import BuildSystem
-from bitten.model import BuildConfig, TargetPlatform, Build, schema
+from trac.web.main import RequestDone
+from bitten.model import BuildConfig, TargetPlatform, schema
 from bitten.admin import BuildMasterAdminPageProvider, \
                          BuildConfigurationsAdminPageProvider
 
@@ -69,7 +66,7 @@
 
     def test_process_get_request(self):
         data = {}
-        req = Mock(method='GET', hdf=data,
+        req = Mock(method='GET', chrome={}, hdf=data, href=Href('/'),
                    perm=PermissionCache(self.env, 'joe'))
 
         provider = BuildMasterAdminPageProvider(self.env)
@@ -153,9 +150,9 @@
         req = Mock(perm=PermissionCache(self.env, 'joe'))
         self.assertEqual([], list(provider.get_admin_pages(req)))
 
-    def test_process_get_request_overview_empty(self):
+    def test_process_view_configs_empty(self):
         data = {}
-        req = Mock(method='GET', hdf=data,
+        req = Mock(method='GET', chrome={}, hdf=data, href=Href('/'),
                    perm=PermissionCache(self.env, 'joe'))
 
         provider = BuildConfigurationsAdminPageProvider(self.env)
@@ -174,7 +171,7 @@
                     min_rev='123', max_rev='456').insert()
 
         data = {}
-        req = Mock(method='GET', hdf=data, href=Href('/'),
+        req = Mock(method='GET', chrome={}, hdf=data, href=Href('/'),
                    perm=PermissionCache(self.env, 'joe'))
 
         provider = BuildConfigurationsAdminPageProvider(self.env)
@@ -203,7 +200,7 @@
         TargetPlatform(self.env, config='foo', name='any').insert()
 
         data = {}
-        req = Mock(method='GET', hdf=data, href=Href('/'),
+        req = Mock(method='GET', chrome={}, hdf=data, href=Href('/'),
                    perm=PermissionCache(self.env, 'joe'))
 
         provider = BuildConfigurationsAdminPageProvider(self.env)
@@ -219,11 +216,34 @@
             'path': 'branches/foo', 'min_rev': None, 'max_rev': None,
             'active': True, 'platforms': [{
                 'href': '/admin/bitten/configs/foo/1',
-                'name': 'any',
-                'id': 1
+                'name': 'any', 'id': 1, 'rules': []
             }]
         }, config)
 
+    def test_process_activate_config(self):
+        BuildConfig(self.env, name='foo', path='branches/foo').insert()
+        BuildConfig(self.env, name='bar', path='branches/bar').insert()
+
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   abs_href=Href('http://example.org/'), redirect=redirect,
+                   authname='joe',
+                   args={'apply': '', 'active': ['foo']})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs', '')
+            self.fail('Expected RequestDone')
+
+        except RequestDone:
+            self.assertEqual('http://example.org/admin/bitten/configs',
+                             redirected_to[0])
+            config = BuildConfig.fetch(self.env, name='foo')
+            self.assertEqual(True, config.active)
+
     def test_process_add_config(self):
         BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
                     active=True).insert()
@@ -234,6 +254,7 @@
             raise RequestDone
         req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
                    abs_href=Href('http://example.org/'), redirect=redirect,
+                   authname='joe',
                    args={'add': '', 'name': 'bar', 'label': 'Bar'})
 
         provider = BuildConfigurationsAdminPageProvider(self.env)
@@ -278,8 +299,9 @@
 
         except TracError, e:
             self.assertEqual('Missing required field "name"', e.message)
+            self.assertEqual('Missing Field', e.title)
 
-    def test_process_add_config_no_name(self):
+    def test_process_add_config_invalid_name(self):
         req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
                    args={'add': '', 'name': 'no spaces allowed'})
 
@@ -291,6 +313,25 @@
         except TracError, e:
             self.assertEqual('The field "name" may only contain letters, '
                              'digits, periods, or dashes.', e.message)
+            self.assertEqual('Invalid Field', e.title)
+
+    def test_new_config_submit_with_invalid_path(self):
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   authname='joe',
+                   args={'add': '', 'name': 'foo', 'path': 'invalid/path'})
+
+        def get_node(path, rev=None):
+            raise TracError('No such node')
+        self.repos = Mock(get_node=get_node)
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs', '')
+            self.fail('Expected TracError')
+
+        except TracError, e:
+            self.assertEqual('No such node', e.message)
+            self.assertEqual('Invalid Repository Path', e.title)
 
     def test_process_add_config_no_perms(self):
         BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
@@ -397,38 +438,7 @@
         self.assertRaises(PermissionError, provider.process_admin_request, req,
                           'bitten', 'configs', '')
 
-    def test_process_update_config_no_name(self):
-        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
-                    active=True).insert()
-
-        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
-                   args={'save': ''})
-
-        provider = BuildConfigurationsAdminPageProvider(self.env)
-        try:
-            provider.process_admin_request(req, 'bitten', 'configs', 'foo')
-            self.fail('Expected TracError')
-
-        except TracError, e:
-            self.assertEqual('Missing required field "name"', e.message)
-
-    def test_process_update_config_invalid_name(self):
-        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
-                    active=True).insert()
-
-        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
-                   args={'save': '', 'name': 'no spaces allowed'})
-
-        provider = BuildConfigurationsAdminPageProvider(self.env)
-        try:
-            provider.process_admin_request(req, 'bitten', 'configs', 'foo')
-            self.fail('Expected TracError')
-
-        except TracError, e:
-            self.assertEqual('The field "name" may only contain letters, '
-                             'digits, periods, or dashes.', e.message)
-
-    def test_process_update_config_valid(self):
+    def test_process_update_config(self):
         BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
                     active=True).insert()
 
@@ -455,6 +465,300 @@
             self.assertEqual('Foobar', config.label)
             self.assertEqual('Thanks for all the fish!', config.description)
 
+    def test_process_update_config_no_name(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   args={'save': ''})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs', 'foo')
+            self.fail('Expected TracError')
+
+        except TracError, e:
+            self.assertEqual('Missing required field "name"', e.message)
+            self.assertEqual('Missing Field', e.title)
+
+    def test_process_update_config_invalid_name(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   args={'save': '', 'name': 'no spaces allowed'})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs', 'foo')
+            self.fail('Expected TracError')
+
+        except TracError, e:
+            self.assertEqual('The field "name" may only contain letters, '
+                             'digits, periods, or dashes.', e.message)
+            self.assertEqual('Invalid Field', e.title)
+
+    def test_process_update_config_invalid_path(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   authname='joe',
+                   args={'save': '', 'name': 'foo', 'path': 'invalid/path'})
+
+        def get_node(path, rev=None):
+            raise TracError('No such node')
+        self.repos = Mock(get_node=get_node)
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs', 'foo')
+            self.fail('Expected TracError')
+
+        except TracError, e:
+            self.assertEqual('No such node', e.message)
+            self.assertEqual('Invalid Repository Path', e.title)
+
+    def test_process_update_config_non_wellformed_recipe(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   authname='joe',
+                   args={'save': '', 'name': 'foo', 'recipe': 'not_xml'})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs', 'foo')
+            self.fail('Expected TracError')
+
+        except TracError, e:
+            self.assertEqual('Failure parsing recipe: syntax error: line 1, '
+                             'column 0', e.message)
+            self.assertEqual('Invalid Recipe', e.title)
+
+    def test_process_update_config_invalid_recipe(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   authname='joe',
+                   args={'save': '', 'name': 'foo',
+                         'recipe': '<build><step /></build>'})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs', 'foo')
+            self.fail('Expected TracError')
+
+        except TracError, e:
+            self.assertEqual('Steps must have an "id" attribute', e.message)
+            self.assertEqual('Invalid Recipe', e.title)
+
+    def test_process_new_platform(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+
+        data = {}
+        req = Mock(method='POST', chrome={}, hdf=data, href=Href('/'),
+                   perm=PermissionCache(self.env, 'joe'),
+                   args={'new': ''})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        template_name, content_type = provider.process_admin_request(
+            req, 'bitten', 'configs', 'foo'
+        )
+
+        self.assertEqual('bitten_admin_configs.cs', template_name)
+        self.assertEqual(None, content_type)
+        platform = data['admin']['platform']
+        self.assertEqual({
+            'id': None, 'exists': False, 'name': None, 'rules': [('', '')],
+        }, platform)
+
+    def test_process_add_platform(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   abs_href=Href('http://example.org/'), redirect=redirect,
+                   authname='joe',
+                   args={'add': '', 'new': '', 'name': 'Test',
+                         'property_0': 'family', 'pattern_0': 'posix'})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs', 'foo')
+            self.fail('Expected RequestDone')
+
+        except RequestDone:
+            self.assertEqual('http://example.org/admin/bitten/configs/foo',
+                             redirected_to[0])
+            platforms = list(TargetPlatform.select(self.env, config='foo'))
+            self.assertEqual(1, len(platforms))
+            self.assertEqual('Test', platforms[0].name)
+            self.assertEqual([('family', 'posix')], platforms[0].rules)
+
+    def test_process_add_platform_cancel(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   abs_href=Href('http://example.org/'), redirect=redirect,
+                   authname='joe',
+                   args={'cancel': '', 'new': '', 'name': 'Test',
+                         'property_0': 'family', 'pattern_0': 'posix'})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs', 'foo')
+            self.fail('Expected RequestDone')
+
+        except RequestDone:
+            self.assertEqual('http://example.org/admin/bitten/configs/foo',
+                             redirected_to[0])
+            platforms = list(TargetPlatform.select(self.env, config='foo'))
+            self.assertEqual(0, len(platforms))
+
+    def test_process_remove_platforms(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+        platform = TargetPlatform(self.env, config='foo', name='any')
+        platform.insert()
+
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   abs_href=Href('http://example.org/'), redirect=redirect,
+                   authname='joe',
+                   args={'remove': '', 'sel': str(platform.id)})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs', 'foo')
+            self.fail('Expected RequestDone')
+
+        except RequestDone:
+            self.assertEqual('http://example.org/admin/bitten/configs/foo',
+                             redirected_to[0])
+            platforms = list(TargetPlatform.select(self.env, config='foo'))
+            self.assertEqual(0, len(platforms))
+
+    def test_process_remove_platforms_no_selection(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+        platform = TargetPlatform(self.env, config='foo', name='any')
+        platform.insert()
+
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   abs_href=Href('http://example.org/'), redirect=redirect,
+                   authname='joe',
+                   args={'remove': ''})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs', 'foo')
+            self.fail('Expected TracError')
+
+        except TracError, e:
+            self.assertEqual('No platform selected', e.message)
+
+    def test_process_edit_platform(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+        platform = TargetPlatform(self.env, config='foo', name='any')
+        platform.insert()
+
+        data = {}
+        req = Mock(method='GET', chrome={}, hdf=data, href=Href('/'),
+                   perm=PermissionCache(self.env, 'joe'), args={})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        template_name, content_type = provider.process_admin_request(
+            req, 'bitten', 'configs', 'foo/%d' % platform.id
+        )
+
+        self.assertEqual('bitten_admin_configs.cs', template_name)
+        self.assertEqual(None, content_type)
+        platform = data['admin']['platform']
+        self.assertEqual({
+            'id': 1, 'exists': True, 'name': 'any', 'rules': [('', '')],
+        }, platform)
+
+    def test_process_update_platform(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+        platform = TargetPlatform(self.env, config='foo', name='any')
+        platform.insert()
+
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   abs_href=Href('http://example.org/'), redirect=redirect,
+                   authname='joe',
+                   args={'save': '', 'edit': '', 'name': 'Test',
+                         'property_0': 'family', 'pattern_0': 'posix'})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs',
+                                           'foo/%d' % platform.id)
+            self.fail('Expected RequestDone')
+
+        except RequestDone:
+            self.assertEqual('http://example.org/admin/bitten/configs/foo',
+                             redirected_to[0])
+            platforms = list(TargetPlatform.select(self.env, config='foo'))
+            self.assertEqual(1, len(platforms))
+            self.assertEqual('Test', platforms[0].name)
+            self.assertEqual([('family', 'posix')], platforms[0].rules)
+
+    def test_process_update_platform_cancel(self):
+        BuildConfig(self.env, name='foo', label='Foo', path='branches/foo',
+                    active=True).insert()
+        platform = TargetPlatform(self.env, config='foo', name='any')
+        platform.insert()
+
+        redirected_to = []
+        def redirect(url):
+            redirected_to.append(url)
+            raise RequestDone
+        req = Mock(method='POST', perm=PermissionCache(self.env, 'joe'),
+                   abs_href=Href('http://example.org/'), redirect=redirect,
+                   authname='joe',
+                   args={'cancel': '', 'edit': '', 'name': 'Changed',
+                         'property_0': 'family', 'pattern_0': 'posix'})
+
+        provider = BuildConfigurationsAdminPageProvider(self.env)
+        try:
+            provider.process_admin_request(req, 'bitten', 'configs',
+                                           'foo/%d' % platform.id)
+            self.fail('Expected RequestDone')
+
+        except RequestDone:
+            self.assertEqual('http://example.org/admin/bitten/configs/foo',
+                             redirected_to[0])
+            platforms = list(TargetPlatform.select(self.env, config='foo'))
+            self.assertEqual(1, len(platforms))
+            self.assertEqual('any', platforms[0].name)
+            self.assertEqual([], platforms[0].rules)
+
 
 def suite():
     suite = unittest.TestSuite()
--- a/bitten/tests/master.py
+++ b/bitten/tests/master.py
@@ -8,7 +8,6 @@
 # you should have received as part of this distribution. The terms
 # are also available at http://bitten.edgewall.org/wiki/License.
 
-from datetime import datetime
 import re
 import shutil
 from StringIO import StringIO
@@ -22,7 +21,6 @@
                          RequestDone
 from trac.web.href import Href
 
-from bitten.main import BuildSystem
 from bitten.master import BuildMaster
 from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, \
                          BuildLog, Report, schema
--- a/bitten/tests/queue.py
+++ b/bitten/tests/queue.py
@@ -16,7 +16,7 @@
 
 from trac.db import DatabaseManager
 from trac.test import EnvironmentStub, Mock
-from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, schema
+from bitten.model import BuildConfig, TargetPlatform, Build, schema
 from bitten.queue import BuildQueue, collect_changes
 
 
--- a/bitten/tests/slave.py
+++ b/bitten/tests/slave.py
@@ -12,7 +12,6 @@
 import shutil
 import tempfile
 import unittest
-import zipfile
 
 from trac.test import Mock
 from bitten.slave import BuildSlave
@@ -22,8 +21,7 @@
 
     def setUp(self):
         self.work_dir = tempfile.mkdtemp(prefix='bitten_test')
-        self.slave = Slave(None, work_dir=self.work_dir)
-        self.handler = OrchestrationProfileHandler(Mock(session=self.slave))
+        self.slave = BuildSlave(None, work_dir=self.work_dir)
 
     def tearDown(self):
         shutil.rmtree(self.work_dir)
--- a/bitten/tests/web_ui.py
+++ b/bitten/tests/web_ui.py
@@ -12,16 +12,12 @@
 import tempfile
 import unittest
 
-from trac.core import TracError
 from trac.db import DatabaseManager
 from trac.perm import PermissionCache, PermissionSystem
 from trac.test import EnvironmentStub, Mock
-from trac.versioncontrol import Repository
 from trac.web.clearsilver import HDFWrapper
 from trac.web.href import Href
-from trac.web.main import Request, RequestDone
-from bitten.main import BuildSystem
-from bitten.model import BuildConfig, TargetPlatform, Build, schema
+from bitten.model import BuildConfig, TargetPlatform, schema
 from bitten.web_ui import BuildConfigController
 
 
@@ -68,18 +64,6 @@
         self.assertEqual('overview', req.hdf['page.mode'])
         self.assertEqual('0', req.hdf.get('build.can_create', '0'))
 
-    def test_overview_admin(self):
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='GET', base_path='', cgi_location='',
-                   path_info='/build', href=Href('/trac'), args={}, chrome={},
-                   hdf=HDFWrapper(), perm=PermissionCache(self.env, 'joe'))
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        module.process_request(req)
-
-        self.assertEqual('1', req.hdf.get('config.can_create'))
-
     def test_view_config(self):
         config = BuildConfig(self.env, name='test', path='trunk')
         config.insert()
@@ -136,415 +120,6 @@
             self.assertEqual('/trac/build/test?page=2',
                              req.hdf.get('chrome.links.next.0.href'))
 
-    def test_view_config_admin(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='GET', base_path='', cgi_location='',
-                   path_info='/build/test', href=Href('/trac'), args={},
-                   chrome={}, hdf=HDFWrapper(), authname='joe',
-                   perm=PermissionCache(self.env, 'joe'))
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        module.process_request(req)
-
-        self.assertEqual('1', req.hdf.get('config.can_delete'))
-        self.assertEqual('1', req.hdf.get('config.can_modify'))
-
-    def test_new_config(self):
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='GET', base_path='', cgi_location='',
-                   path_info='/build', args={'action': 'new'}, hdf=HDFWrapper(),
-                   href=Href('/trac'), chrome={},
-                   perm=PermissionCache(self.env, 'joe'))
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        module.process_request(req)
-
-        self.assertEqual('edit_config', req.hdf['page.mode'])
-
-    def test_new_config_submit(self):
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        redirected_to = []
-        def redirect(url):
-            redirected_to.append(url)
-            raise RequestDone
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build', href=Href('/trac'), redirect=redirect,
-                   hdf=HDFWrapper(), authname='joe',
-                   perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'new', 'name': 'test', 'path': 'test/trunk',
-                         'label': 'Test', 'description': 'Bla bla'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(RequestDone, module.process_request, req)
-        self.assertEqual('/trac/build/test', redirected_to[0])
-
-        config = BuildConfig.fetch(self.env, 'test')
-        assert config.exists
-        assert not config.active
-        self.assertEqual('Test', config.label)
-        self.assertEqual('test/trunk', config.path)
-        self.assertEqual('Bla bla', config.description)
-
-    def test_new_config_submit_without_name(self):
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build', href=Href('/trac'), hdf=HDFWrapper(),
-                   perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'new', 'name': '', 'path': 'test/trunk',
-                         'label': 'Test', 'description': 'Bla bla'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(TracError, module.process_request, req)
-
-    def test_new_config_submit_with_invalid_name(self):
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build', href=Href('/trac'), hdf=HDFWrapper(),
-                   perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'new', 'name': 'Foo bar',
-                         'path': 'test/trunk', 'label': 'Test',
-                         'description': 'Bla bla'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(TracError, module.process_request, req)
-
-    def test_new_config_submit_invalid_path(self):
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build', href=Href('/trac'), hdf=HDFWrapper(),
-                   authname='joe', perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'new', 'name': 'test', 'path': 'test/trunk',
-                         'label': 'Test', 'description': 'Bla bla'})
-
-        def get_node(path, rev=None):
-            raise TracError('No such node')
-        self.repos = Mock(get_node=get_node)
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(TracError, module.process_request, req)
-
-    def test_new_config_submit_with_non_wellformed_recipe(self):
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build', href=Href('/trac'), hdf=HDFWrapper(),
-                   authname='joe', perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'new', 'name': 'test', 'path': 'test/trunk',
-                         'label': 'Test', 'description': 'Bla bla',
-                         'recipe': '<build><step>'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(TracError, module.process_request, req)
-
-    def test_new_config_submit_with_invalid_recipe(self):
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build', href=Href('/trac'), hdf=HDFWrapper(),
-                   authname='joe', perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'new', 'name': 'test', 'path': 'test/trunk',
-                         'label': 'Test', 'description': 'Bla bla',
-                         'recipe': '<build><step/></build>'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(TracError, module.process_request, req)
-
-    def test_new_config_cancel(self):
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        redirected_to = []
-        def redirect(url):
-            redirected_to.append(url)
-            raise RequestDone
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build', href=Href('/trac'), redirect=redirect,
-                   hdf=HDFWrapper(), perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'new', 'cancel': '1', 'name': 'test'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(RequestDone, module.process_request, req)
-        self.assertEqual('/trac/build', redirected_to[0])
-
-        self.assertEqual(None, BuildConfig.fetch(self.env, 'test'))
-
-    def test_delete_config(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='GET', base_path='', cgi_location='',
-                   path_info='/build/test', href=Href('/trac'), chrome={},
-                   hdf=HDFWrapper(), perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'delete'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        module.process_request(req)
-
-        self.assertEqual('delete_config', req.hdf['page.mode'])
-
-    def test_delete_config_submit(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        redirected_to = []
-        def redirect(url):
-            redirected_to.append(url)
-            raise RequestDone
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build/test', href=Href('/trac'),
-                   redirect=redirect, hdf=HDFWrapper(),
-                   perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'delete'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(RequestDone, module.process_request, req)
-        self.assertEqual('/trac/build', redirected_to[0])
-
-        self.assertEqual(None, BuildConfig.fetch(self.env, 'test'))
-
-    def test_edit_config_cancel(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        redirected_to = []
-        def redirect(url):
-            redirected_to.append(url)
-            raise RequestDone
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build/test', href=Href('/trac'),
-                   redirect=redirect, hdf=HDFWrapper(),
-                   perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'delete', 'cancel': ''})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(RequestDone, module.process_request, req)
-        self.assertEqual('/trac/build/test', redirected_to[0])
-
-        self.assertEqual(True, BuildConfig.fetch(self.env, 'test').exists)
-
-    def test_edit_config(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='GET', base_path='', cgi_location='',
-                   path_info='/build/test', hdf=HDFWrapper(),
-                   href=Href('/build/test'), chrome={},
-                   perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'edit'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        module.process_request(req)
-
-        self.assertEqual('edit_config', req.hdf['page.mode'])
-
-    def test_edit_config_submit(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        redirected_to = []
-        def redirect(url):
-            redirected_to.append(url)
-            raise RequestDone
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build/test', href=Href('/trac'),
-                   redirect=redirect, hdf=HDFWrapper(),
-                   authname='joe', perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'edit', 'name': 'foo', 'path': 'test/trunk',
-                         'label': 'Test',  'description': 'Bla bla'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(RequestDone, module.process_request, req)
-        self.assertEqual('/trac/build/foo', redirected_to[0])
-
-        self.assertEqual(None, BuildConfig.fetch(self.env, 'test'))
-
-        config = BuildConfig.fetch(self.env, 'foo')
-        assert config.exists
-        self.assertEqual('Test', config.label)
-        self.assertEqual('test/trunk', config.path)
-        self.assertEqual('Bla bla', config.description)
-
-    def test_edit_config_cancel(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        redirected_to = []
-        def redirect(url):
-            redirected_to.append(url)
-            raise RequestDone
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build/test', href=Href('/trac'),
-                   redirect=redirect, hdf=HDFWrapper(),
-                   perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'edit', 'cancel': ''})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(RequestDone, module.process_request, req)
-        self.assertEqual('/trac/build/test', redirected_to[0])
-
-    def test_new_platform(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='GET', base_path='', cgi_location='',
-                   path_info='/build/test', hdf=HDFWrapper(), href=Href('trac'),
-                   chrome={}, perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'edit', 'new': '1'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        module.process_request(req)
-
-        self.assertEqual('edit_platform', req.hdf['page.mode'])
-
-    def test_new_platform_submit(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        redirected_to = []
-        def redirect(url):
-            redirected_to.append(url)
-            raise RequestDone
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build/test', href=Href('/trac'),
-                   redirect=redirect, hdf=HDFWrapper(),
-                   perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'new', 'name': 'Test'})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(RequestDone, module.process_request, req)
-        self.assertEqual('/trac/build/test?action=edit', redirected_to[0])
-
-    def test_new_platform_cancel(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
- 
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        redirected_to = []
-        def redirect(url):
-            redirected_to.append(url)
-            raise RequestDone
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build/test', href=Href('/trac'),
-                   redirect=redirect, hdf=HDFWrapper(),
-                   perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'new', 'cancel': ''})
- 
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(RequestDone, module.process_request, req)
-        self.assertEqual('/trac/build/test?action=edit', redirected_to[0])
-
-    def test_edit_platform(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-        platform = TargetPlatform(self.env)
-        platform.config = 'test'
-        platform.name = 'linux'
-        platform.rules.append(('os', 'linux?'))
-        platform.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        req = Mock(method='GET', base_path='', cgi_location='',
-                   path_info='/build/test', hdf=HDFWrapper(),
-                   href=Href('/trac'), chrome={},
-                   perm=PermissionCache(self.env, 'joe'),
-                   args={'action': 'edit', 'platform': platform.id})
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        module.process_request(req)
-
-        self.assertEqual('edit_platform', req.hdf['page.mode'])
-
-    def test_edit_platform_submit(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-        platform = TargetPlatform(self.env)
-        platform.config = 'test'
-        platform.name = 'linux'
-        platform.rules.append(('os', 'linux?'))
-        platform.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        redirected_to = []
-        def redirect(url):
-            redirected_to.append(url)
-            raise RequestDone
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build/test', href=Href('/trac'),
-                   redirect=redirect, hdf=HDFWrapper(),
-                   args={'action': 'edit', 'platform': platform.id,
-                         'name': 'Test'},
-                   perm=PermissionCache(self.env, 'joe'))
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(RequestDone, module.process_request, req)
-        self.assertEqual('/trac/build/test?action=edit', redirected_to[0])
-
-    def test_edit_platform_cancel(self):
-        config = BuildConfig(self.env)
-        config.name = 'test'
-        config.insert()
-        platform = TargetPlatform(self.env)
-        platform.config = 'test'
-        platform.name = 'linux'
-        platform.rules.append(('os', 'linux'))
-        platform.insert()
-
-        PermissionSystem(self.env).grant_permission('joe', 'BUILD_ADMIN')
-        redirected_to = []
-        def redirect(url):
-            redirected_to.append(url)
-            raise RequestDone
-        req = Mock(method='POST', base_path='', cgi_location='',
-                   path_info='/build/test', href=Href('/trac'),
-                   redirect=redirect, hdf=HDFWrapper(),
-                   args={'action': 'edit', 'platform': platform.id,
-                         'cancel': ''},
-                   perm=PermissionCache(self.env, 'joe'))
-
-        module = BuildConfigController(self.env)
-        assert module.match_request(req)
-        self.assertRaises(RequestDone, module.process_request, req)
-        self.assertEqual('/trac/build/test?action=edit', redirected_to[0])
-
 
 def suite():
     return unittest.makeSuite(BuildConfigControllerTestCase, 'test')
--- a/bitten/web_ui.py
+++ b/bitten/web_ui.py
@@ -13,10 +13,6 @@
 from datetime import datetime
 import posixpath
 import re
-try:
-    set
-except NameError:
-    from sets import Set as set
 from StringIO import StringIO
 
 import pkg_resources
@@ -35,8 +31,6 @@
 from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, \
                          BuildLog, Report
 from bitten.queue import collect_changes
-from bitten.recipe import Recipe, InvalidRecipeError
-from bitten.util import xmlio
 
 _status_label = {Build.PENDING: 'pending',
                  Build.IN_PROGRESS: 'in progress',
@@ -125,230 +119,18 @@
         view = req.args.get('view')
         config = req.args.get('config')
 
-        if req.method == 'POST':
-            if config:
-                if action == 'new':
-                    self._do_create_platform(req, config)
-                elif action == 'delete':
-                    self._do_delete_config(req, config)
-                else:
-                    platform_id = req.args.get('platform')
-                    if platform_id:
-                        if action == 'edit':
-                            self._do_save_platform(req, config, platform_id)
-                    elif 'delete' in req.args:
-                        self._do_delete_platforms(req)
-                        self._render_config_form(req, config)
-                    elif 'new' in req.args:
-                        platform = TargetPlatform(self.env, config=config)
-                        self._render_platform_form(req, platform)
-                    else:
-                        self._do_save_config(req, config)
-            else:
-                if action == 'new':
-                    self._do_create_config(req)
+        if config:
+            self._render_config(req, config)
+        elif view == 'inprogress':
+            self._render_inprogress(req)
         else:
-            if config:
-                if action == 'delete':
-                    self._render_config_confirm(req, config)
-                elif action == 'edit':
-                    platform_id = req.args.get('platform')
-                    if platform_id:
-                        platform = TargetPlatform.fetch(self.env,
-                                                        int(platform_id))
-                        self._render_platform_form(req, platform)
-                    elif 'new' in req.args:
-                        platform = TargetPlatform(self.env, config=config)
-                        self._render_platform_form(req, platform)
-                    else:
-                        self._render_config_form(req, config)
-                else:
-                    self._render_config(req, config)
-            else:
-                if action == 'new':
-                    self._render_config_form(req)
-                elif view == 'inprogress':
-                    self._render_inprogress(req)
-                else:
-                    self._render_overview(req)
+            self._render_overview(req)
 
         add_stylesheet(req, 'bitten/bitten.css')
         return 'bitten_config.cs', None
 
     # Internal methods
 
-    def _do_create_config(self, req):
-        """Create a new build configuration."""
-        req.perm.assert_permission('BUILD_CREATE')
-
-        if 'cancel' in req.args:
-            req.redirect(req.href.build())
-
-        config_name = req.args.get('name')
-
-        if BuildConfig.fetch(self.env, config_name):
-            raise TracError('A build configuration with the name "%s" already '
-                            'exists' % config_name, 'Duplicate name')
-
-        config = BuildConfig(self.env)
-        self._process_config(req, config)
-        config.insert()
-
-        req.redirect(req.href.build(config.name))
-
-    def _do_delete_config(self, req, config_name):
-        """Save changes to a build configuration."""
-        req.perm.assert_permission('BUILD_DELETE')
-
-        if 'cancel' in req.args:
-            req.redirect(req.href.build(config_name))
-
-        db = self.env.get_db_cnx()
-
-        config = BuildConfig.fetch(self.env, config_name, db=db)
-        assert config, 'Build configuration "%s" does not exist' % config_name
-
-        config.delete(db=db)
-
-        db.commit()
-
-        req.redirect(req.href.build())
-
-    def _do_save_config(self, req, config_name):
-        """Save changes to a build configuration."""
-        req.perm.assert_permission('BUILD_MODIFY')
-
-        if 'cancel' in req.args:
-            req.redirect(req.href.build(config_name))
-
-        config = BuildConfig.fetch(self.env, config_name)
-        if not config:
-            # FIXME: 404
-            raise TracError('Build configuration "%s" does not exist'
-                            % config_name, 'Object not found')
-
-        if 'activate' in req.args:
-            config.active = True
-
-        elif 'deactivate' in req.args:
-            config.active = False
-
-        else:
-            self._process_config(req, config)
-
-        config.update()
-        req.redirect(req.href.build(config.name))
-
-    def _process_config(self, req, config):
-        name = req.args.get('name')
-        if not name:
-            raise TracError('Missing required field "name"', 'Missing field')
-        if not re.match(r'^[\w.-]+$', name):
-            raise TracError('The field "name" may only contain letters, '
-                            'digits, periods, or dashes.', 'Invalid field')
-
-        path = req.args.get('path', '')
-        repos = self.env.get_repository(req.authname)
-        max_rev = req.args.get('max_rev') or None
-        try:
-            node = repos.get_node(path, max_rev)
-            assert node.isdir, '%s is not a directory' % node.path
-        except (AssertionError, TracError), e:
-            raise TracError(e, 'Invalid repository path')
-        if req.args.get('min_rev'):
-            try:
-                repos.get_node(path, req.args.get('min_rev'))
-            except TracError, e:
-                raise TracError(e, 'Invalid value for oldest revision')
-
-        recipe_xml = req.args.get('recipe', '')
-        if recipe_xml:
-            try:
-                Recipe(xmlio.parse(recipe_xml)).validate()
-            except xmlio.ParseError, e:
-                raise TracError('Failure parsing recipe: %s' % e,
-                                'Invalid recipe')
-            except InvalidRecipeError, e:
-                raise TracError(e, 'Invalid recipe')
-
-        config.name = name
-        config.path = repos.normalize_path(path)
-        config.recipe = recipe_xml
-        config.min_rev = req.args.get('min_rev')
-        config.max_rev = req.args.get('max_rev')
-        config.label = req.args.get('label', '')
-        config.description = req.args.get('description', '')
-
-    def _do_create_platform(self, req, config_name):
-        """Create a new target platform."""
-        req.perm.assert_permission('BUILD_MODIFY')
-
-        if 'cancel' in req.args:
-            req.redirect(req.href.build(config_name, action='edit'))
-
-        platform = TargetPlatform(self.env, config=config_name)
-        if self._process_platform(req, platform):
-            platform.insert()
-            req.redirect(req.href.build(config_name, action='edit'))
-
-    def _do_delete_platforms(self, req):
-        """Delete selected target platforms."""
-        req.perm.assert_permission('BUILD_MODIFY')
-        self.log.debug('_do_delete_platforms')
-
-        db = self.env.get_db_cnx()
-        for platform_id in [int(id) for id in req.args.get('delete_platform')]:
-            platform = TargetPlatform.fetch(self.env, platform_id, db=db)
-            self.log.info('Deleting target platform %s of configuration %s',
-                          platform.name, platform.config)
-            platform.delete(db=db)
-
-            # FIXME: this should probably also delete all builds done for this
-            # platform, and all the associated reports
-
-        db.commit()
-
-    def _do_save_platform(self, req, config_name, platform_id):
-        """Save changes to a target platform."""
-        req.perm.assert_permission('BUILD_MODIFY')
-
-        if 'cancel' in req.args:
-            req.redirect(req.href.build(config_name, action='edit'))
-
-        platform = TargetPlatform.fetch(self.env, platform_id)
-        if self._process_platform(req, platform):
-            platform.update()
-            req.redirect(req.href.build(config_name, action='edit'))
-
-    def _process_platform(self, req, platform):
-        platform.name = req.args.get('name')
-
-        properties = [int(key[9:]) for key in req.args.keys()
-                      if key.startswith('property_')]
-        properties.sort()
-        patterns = [int(key[8:]) for key in req.args.keys()
-                    if key.startswith('pattern_')]
-        patterns.sort()
-        platform.rules = [(req.args.get('property_%d' % property),
-                           req.args.get('pattern_%d' % pattern))
-                          for property, pattern in zip(properties, patterns)
-                          if req.args.get('property_%d' % property)]
-
-        add_rules = [int(key[9:]) for key in req.args.keys()
-                     if key.startswith('add_rule_')]
-        if add_rules:
-            platform.rules.insert(add_rules[0] + 1, ('', ''))
-            self._render_platform_form(req, platform)
-            return False
-        rm_rules = [int(key[8:]) for key in req.args.keys()
-                    if key.startswith('rm_rule_')]
-        if rm_rules:
-            del platform.rules[rm_rules[0]]
-            self._render_platform_form(req, platform)
-            return False
-
-        return True
-
     def _render_overview(self, req):
         req.hdf['title'] = 'Build Status'
         show_all = False
@@ -356,8 +138,6 @@
             show_all = True
         req.hdf['config.show_all'] = show_all
 
-        add_link(req, 'views', req.href.build(view='inprogress'), 'In Progress Builds')
-
         configs = BuildConfig.select(self.env, include_inactive=show_all)
         for idx, config in enumerate(configs):
             prefix = 'configs.%d' % idx
@@ -402,7 +182,8 @@
                     }
 
         req.hdf['page.mode'] = 'overview'
-        req.hdf['config.can_create'] = req.perm.has_permission('BUILD_CREATE')
+        add_link(req, 'views', req.href.build(view='inprogress'),
+                 'In Progress Builds')
 
     def _render_inprogress(self, req):
         req.hdf['title'] = 'In Progress Builds'
@@ -475,9 +256,7 @@
             'max_rev': config.max_rev,
             'max_rev_href': req.href.changeset(config.max_rev),
             'active': config.active, 'description': description,
-            'browser_href': req.href.browser(config.path),
-            'can_modify': req.perm.has_permission('BUILD_MODIFY'),
-            'can_delete': req.perm.has_permission('BUILD_DELETE')
+            'browser_href': req.href.browser(config.path)
         }
         req.hdf['page.mode'] = 'view_config'
 
@@ -547,53 +326,6 @@
             next_href = req.href.build(config.name, page=page + 1)
             add_link(req, 'next', next_href, 'Next Page')
 
-    def _render_config_confirm(self, req, config_name):
-        req.perm.assert_permission('BUILD_DELETE')
-        config = BuildConfig.fetch(self.env, config_name)
-        req.hdf['title'] = 'Delete Build Configuration "%s"' \
-                           % config.label or config.name
-        req.hdf['config'] = {'name': config.name}
-        req.hdf['page.mode'] = 'delete_config'
-
-    def _render_config_form(self, req, config_name=None):
-        config = BuildConfig.fetch(self.env, config_name)
-        if config:
-            req.perm.assert_permission('BUILD_MODIFY')
-            req.hdf['config'] = {
-                'name': config.name, 'exists': config.exists,
-                'path': config.path, 'active': config.active,
-                'recipe': config.recipe, 'min_rev': config.min_rev,
-                'max_rev': config.max_rev, 'label': config.label,
-                'description': config.description
-            }
-
-            req.hdf['title'] = 'Edit Build Configuration "%s"' \
-                               % config.label or config.name
-            for idx, platform in enumerate(TargetPlatform.select(self.env,
-                                                                 config_name)):
-                req.hdf['config.platforms.%d' % idx] = {
-                    'id': platform.id, 'name': platform.name,
-                    'href': req.href.build(config_name, action='edit',
-                                           platform=platform.id)
-                }
-        else:
-            req.perm.assert_permission('BUILD_CREATE')
-            req.hdf['title'] = 'Create Build Configuration'
-        req.hdf['page.mode'] = 'edit_config'
-
-    def _render_platform_form(self, req, platform):
-        req.perm.assert_permission('BUILD_MODIFY')
-        if platform.exists:
-            req.hdf['title'] = 'Edit Target Platform "%s"' % platform.name
-        else:
-            req.hdf['title'] = 'Add Target Platform'
-        req.hdf['platform'] = {
-            'name': platform.name, 'id': platform.id, 'exists': platform.exists,
-            'rules': [{'property': propname, 'pattern': pattern}
-                      for propname, pattern in platform.rules] or [('', '')]
-        }
-        req.hdf['page.mode'] = 'edit_platform'
-
 
 class BuildController(Component):
     """Renders the build page."""
--- a/doc/install.txt
+++ b/doc/install.txt
@@ -39,9 +39,12 @@
 done. You might need to install software that the build of your project
 requires, but the Bitten build slave itself doesn't require anything extra.
 
-For the build master and web interface, you'll need to install Trac 0.10 or
-later. Please refer to the Trac documentation for information on how it is
-installed.
+For the build master and web interface, you'll need to install Trac_ 0.10 or
+later and the TracWebAdmin_ plugin. Please refer to the Trac documentation for
+information on how it is installed.
+
+.. _trac: http://trac.edgewall.org/
+.. _tracwebadmin: http://trac.edgewall.org/wiki/WebAdmin
 
 
 Build Master Configuration
@@ -53,7 +56,7 @@
 
 If you already have a Trac project environment, the Bitten plugin needs to be
 explicitly enabled in the Trac configuration. This is done by adding it to the
-[components] section in /path/to/projenv/conf/trac.ini:
+``[components]`` section in ``/path/to/projenv/conf/trac.ini``:
 
 .. code-block:: ini
 
@@ -76,11 +79,27 @@
 
 You should now see an additional tab labeled "Build Status" in the Trac
 navigation bar. This link will take you to the list of build configurations,
-which at this point is of course empty. If you've set up permissions
-correctly as described previously, you should see a button for adding new
-build configurations. Click that button and fill out the form. Also, add
-at least one target platform after saving the configuration. Last but not
-least, you'll have to "activate" the build configuration.
+which at this point is of course empty.
+
+To add build configurations, you need to have the TracWebAdmin_ plugin
+installed.
+
+.. warning:: The TracWebAdmin needs to be installed even if you're using Trac
+             0.11 or later, which basically provides a builtin web
+             administration interface. Make sure that you disable the plugin in
+             that case, though (``webadmin.* = disabled``). While somewhat
+             counterintuitive, this process allows the Bitten administration UI
+             to neatly integrate into the new web administration interface added
+             in Trac 0.11.
+
+If both TracWebAdmin_ and Bitten are installed, and you are logged in as a user
+with the required permissions, you should see additional administration pages
+inside the “Admin” area, under a group named “Builds”. These pages allow you to
+set options of the build master, and manage build configurations.
+
+Add a new build configuration and fill out the form. Also, add at least one
+target platform after saving the configuration. Last but not least, you'll have
+to "activate" your new build configuration.
 
 
 Running the Build Slave
Copyright (C) 2012-2017 Edgewall Software