Index: /trunk/src/VBox/ValidationKit/testmanager/core/base.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/base.py	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/base.py	(revision 61502)
@@ -413,6 +413,7 @@
         Child classes can override to do special parameter conversion jobs.
         """
-        sPrefix = self.getHungarianPrefix(sAttr);
+        sPrefix       = self.getHungarianPrefix(sAttr);
         asValidValues = getattr(self, 'kasValidValues_' + sAttr, None);
+        fAllowNull    = sAttr in getattr(self, 'kasAllowNullAttributes', list());
         if fStrict:
             if sPrefix == 'f':
@@ -423,5 +424,5 @@
                 oNewValue = oDisp.getListOfStrParams(sParam, []);
             else:
-                oNewValue = oDisp.getStringParam(sParam, asValidValues, None);
+                oNewValue = oDisp.getStringParam(sParam, asValidValues, None, fAllowNull = fAllowNull);
         else:
             if sPrefix[0] == 'a':
@@ -429,5 +430,5 @@
             else:
                 assert oValue is not None, 'sAttr=%s' % (sAttr,);
-                oNewValue = oDisp.getStringParam(sParam, asValidValues, oValue);
+                oNewValue = oDisp.getStringParam(sParam, asValidValues, oValue, fAllowNull = fAllowNull);
         return oNewValue;
 
@@ -1053,7 +1054,7 @@
             self.oDisp    = oDisp;
             self.sAttrFmt = sAttrFmt;
-        def getStringParam(self, sName, asValidValues = None, sDefault = None):
+        def getStringParam(self, sName, asValidValues = None, sDefault = None, fAllowNull = False):
             """See WuiDispatcherBase.getStringParam."""
-            return self.oDisp.getStringParam(self.sAttrFmt % (sName,), asValidValues, sDefault);
+            return self.oDisp.getStringParam(self.sAttrFmt % (sName,), asValidValues, sDefault, fAllowNull = fAllowNull);
         def getListOfStrParams(self, sName, asDefaults = None):
             """See WuiDispatcherBase.getListOfStrParams."""
@@ -1133,6 +1134,6 @@
     def testInitFromParams(self):
         class DummyDisp(object):
-            def getStringParam(self, sName, asValidValues = None, sDefault = None):
-                _ = sName; _ = asValidValues;
+            def getStringParam(self, sName, asValidValues = None, sDefault = None, fAllowNull = False):
+                _ = sName; _ = asValidValues; _ = fAllowNull;
                 return sDefault;
             def getListOfStrParams(self, sName, asDefaults = None):
Index: /trunk/src/VBox/ValidationKit/testmanager/core/db.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/db.py	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/db.py	(revision 61502)
@@ -100,4 +100,11 @@
     """
     return dbTimestampToZuluDatetime(datetime.datetime.utcnow());
+
+def dbTimestampMinusOneTick(oValue):
+    """
+    Returns a new timestamp that's one tick before the given one.
+    """
+    oValue = dbTimestampToZuluDatetime(oValue);
+    return oValue - datetime.timedelta(microseconds = 1);
 
 def isDbInterval(oValue):
Index: /trunk/src/VBox/ValidationKit/testmanager/core/schedgroup.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/schedgroup.py	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/schedgroup.py	(revision 61502)
@@ -259,11 +259,11 @@
     def __init__(self):
         SchedGroupData.__init__(self);
-        self.aoMembers          = [];   # SchedGroupMemberDataEx.
+        self.aoMembers          = [];       # type: SchedGroupMemberDataEx
 
         # Two build sources for convenience sake.
-        self.oBuildSrc          = None;
-        self.oBuildSrcValidationKit = None;
+        self.oBuildSrc          = None;     # type: TestBoxData
+        self.oBuildSrcValidationKit = None; # type: TestBoxData
         # List of test boxes that uses this group for convenience.
-        self.aoTestBoxes        = None;
+        self.aoTestBoxes        = None;     # type: list[TestBoxData]
 
     def _initExtraMembersFromDb(self, oDb, tsNow = None, sPeriodBack = None):
@@ -294,20 +294,13 @@
         # Test Boxes.
         #
-        ## @todo sPeriodBack!
-        if tsNow is None:
-            oDb.execute('SELECT *\n'
-                        'FROM   TestBoxesWithStrings\n'
-                        'WHERE  TestBoxesWithStrings.idSchedGroup = %s\n'
-                        '   AND TestBoxesWithStrings.tsExpire     = \'infinity\'::TIMESTAMP\n'
-                        'ORDER BY TestBoxesWithStrings.sName, TestBoxesWithStrings.idTestBox\n'
-                        , (self.idSchedGroup,));
-        else:
-            oDb.execute('SELECT *\n'
-                        'FROM   TestBoxesWithStrings\n'
-                        'WHERE  TestBoxesWithStrings.idSchedGroup = %s\n'
-                        '   AND TestBoxesWithStrings.tsExpire     > %s\n'
-                        '   AND TestBoxesWithStrings.tsEffective  <= %s\n'
-                        'ORDER BY TestBoxesWithStrings.sName, TestBoxesWithStrings.idTestBox\n'
-                        , (self.idSchedGroup, tsNow, tsNow, tsNow, tsNow));
+        oDb.execute('SELECT TestBoxesWithStrings.*\n'
+                    'FROM   TestBoxesWithStrings,\n'
+                    '       TestBoxesInSchedGroups\n'
+                    'WHERE  TestBoxesInSchedGroups.idSchedGroup = %s\n'
+                    + self.formatSimpleNowAndPeriod(oDb, tsNow, sPeriodBack, sTablePrefix = 'TestBoxesInSchedGroups.') +
+                    '   AND TestBoxesWithStrings.idTestBox      = TestBoxesInSchedGroups.idTestBox\n'
+                    + self.formatSimpleNowAndPeriod(oDb, tsNow, sPeriodBack, sTablePrefix = 'TestBoxesWithStrings.') +
+                    'ORDER BY TestBoxesWithStrings.sName, TestBoxesWithStrings.idTestBox\n'
+                    , (self.idSchedGroup,));
         for aoRow in oDb.fetchAll():
             self.aoTestBoxes.append(TestBoxData().initFromDbRow(aoRow));
@@ -316,26 +309,12 @@
         # Test groups.
         #
-        ## @todo sPeriodBack!
-        if tsNow is None:
-            oDb.execute('SELECT SchedGroupMembers.*, TestGroups.*\n'
-                        'FROM   SchedGroupMembers\n'
-                        'LEFT OUTER JOIN TestGroups ON (SchedGroupMembers.idTestGroup = TestGroups.idTestGroup)\n'
-                        'WHERE  SchedGroupMembers.idSchedGroup = %s\n'
-                        '   AND SchedGroupMembers.tsExpire     = \'infinity\'::TIMESTAMP\n'
-                        '   AND TestGroups.tsExpire            = \'infinity\'::TIMESTAMP\n'
-                        'ORDER BY SchedGroupMembers.idTestGroupPreReq, SchedGroupMembers.idTestGroup\n'
-                        , (self.idSchedGroup,));
-        else:
-            oDb.execute('SELECT SchedGroupMembers.*, TestGroups.*\n'
-                        'FROM   SchedGroupMembers\n'
-                        'LEFT OUTER JOIN TestGroups ON (SchedGroupMembers.idTestGroup = TestGroups.idTestGroup)\n'
-                        'WHERE  SchedGroupMembers.idSchedGroup = %s\n'
-                        '   AND SchedGroupMembers.tsExpire     > %s\n'
-                        '   AND SchedGroupMembers.tsEffective <= %s\n'
-                        '   AND TestGroups.tsExpire            > %s\n'
-                        '   AND TestGroups.tsEffective        <= %s\n'
-                        'ORDER BY SchedGroupMembers.idTestGroupPreReq, SchedGroupMembers.idTestGroup\n'
-                        , (self.idSchedGroup, tsNow, tsNow, tsNow, tsNow));
-
+        oDb.execute('SELECT SchedGroupMembers.*, TestGroups.*\n'
+                    'FROM   SchedGroupMembers\n'
+                    'LEFT OUTER JOIN TestGroups ON (SchedGroupMembers.idTestGroup = TestGroups.idTestGroup)\n'
+                    'WHERE  SchedGroupMembers.idSchedGroup = %s\n'
+                    + self.formatSimpleNowAndPeriod(oDb, tsNow, sPeriodBack, sTablePrefix = 'SchedGroupMembers.')
+                    + self.formatSimpleNowAndPeriod(oDb, tsNow, sPeriodBack, sTablePrefix = 'TestGroups.') +
+                    'ORDER BY SchedGroupMembers.idTestGroupPreReq, SchedGroupMembers.idTestGroup\n'
+                    , (self.idSchedGroup,));
         for aoRow in oDb.fetchAll():
             self.aoMembers.append(SchedGroupMemberDataEx().initFromDbRow(aoRow));
Index: /trunk/src/VBox/ValidationKit/testmanager/core/schedulerbase.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/schedulerbase.py	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/schedulerbase.py	(revision 61502)
@@ -44,5 +44,5 @@
 from testmanager.core.schedgroup        import SchedGroupData, SchedGroupLogic;
 from testmanager.core.systemlog         import SystemLogData, SystemLogLogic;
-from testmanager.core.testbox           import TestBoxData;
+from testmanager.core.testbox           import TestBoxData, TestBoxDataEx;
 from testmanager.core.testboxstatus     import TestBoxStatusData, TestBoxStatusLogic;
 from testmanager.core.testcase          import TestCaseLogic;
@@ -783,5 +783,5 @@
         # Instantiate the specified scheduler and let it do the rest.
         #
-        oSchedGrpData = SchedGroupData().initFromDbWithId(oDb, oTestBox.idSchedGroup, oTestSet.tsCreated);
+        oSchedGrpData = SchedGroupData().initFromDbWithId(oDb, oTestSet.idSchedGroup, oTestSet.tsCreated);
         assert oSchedGrpData.fEnabled   is True;
         assert oSchedGrpData.idBuildSrc is not None;
@@ -838,7 +838,9 @@
 
     def _createTestSet(self, oTask, oTestEx, oTestBoxData, oBuild, oValidationKitBuild, tsNow):
+        # type: (SchedQueueData, TestCaseArgsDataEx, TestBoxData, BuildDataEx, BuildDataEx, datetime.datetime) -> int
         """
         Creates a test set for using the given data.
         Will not commit, someone up the callstack will that later on.
+
         Returns the test set ID, may raise an exception on database error.
         """
@@ -918,5 +920,5 @@
                               oTestBoxData.idGenTestBox,
                               oTestBoxData.idTestBox,
-                              oTestBoxData.idSchedGroup,
+                              oTask.idSchedGroup,
                               oTask.idTestGroup,
                               oTestEx.oTestCase.idGenTestCase,
@@ -1289,8 +1291,60 @@
 
     @staticmethod
-    def scheduleNewTask(oDb, oTestBoxData, sBaseUrl, iVerbosity = 0):
-        """
-        Schedules a new task.
-        """
+    def _pickSchedGroup(oTestBoxDataEx, iWorkItem):
+        """
+        Picks the next scheduling group for the given testbox.
+        """
+        if len(oTestBoxDataEx.aoInSchedGroups) == 1:
+            oSchedGroup = oTestBoxDataEx.aoInSchedGroups[0].oSchedGroup;
+            if oSchedGroup.fEnabled and oSchedGroup.idBuildSrc is not None:
+                return (oSchedGroup, 0);
+
+        elif len(oTestBoxDataEx.aoInSchedGroups) > 0:
+            # Construct priority table of currently enabled scheduling groups.
+            aaoList1 = [];
+            for oInGroup in oTestBoxDataEx.aoInSchedGroups:
+                oSchedGroup = oInGroup.oSchedGroup;
+                if oSchedGroup.fEnabled and oSchedGroup.idBuildSrc is not None:
+                    iSchedPriority = oInGroup.iSchedPriority;
+                    if iSchedPriority > 31:     # paranoia
+                        iSchedPriority = 31;
+                    elif iSchedPriority < 0:    # paranoia
+                        iSchedPriority = 0;
+
+                    for iSchedPriority in xrange(min(iSchedPriority, len(aaoList1))):
+                        aaoList1[iSchedPriority].append(oSchedGroup);
+                    while len(aaoList1) <= iSchedPriority:
+                        aaoList1.append([oSchedGroup,]);
+
+            # Flatten it into a single list, mixing the priorities a little so it doesn't
+            # take forever before low priority stuff is executed.
+            aoFlat = [];
+            iLo    = 0;
+            iHi    = len(aaoList1) - 1;
+            while iHi >= iLo:
+                aoFlat += aaoList1[iHi];
+                if iLo < iHi:
+                    aoFlat += aaoList1[iLo];
+                iLo += 1;
+                iHi -= 1;
+
+            # Pick the next one.
+            iWorkItem += 1;
+            if iWorkItem >= len(aoFlat):
+                iWorkItem = 0;
+            if iWorkItem < len(aoFlat):
+                return (aoFlat[iWorkItem], iWorkItem);
+
+        # No active group.
+        return (None, 0);
+
+    @staticmethod
+    def scheduleNewTask(oDb, oTestBoxData, iWorkItem, sBaseUrl, iVerbosity = 0):
+        # type: (TMDatabaseConnection, TestBoxData, int, str, int) -> None
+        """
+        Schedules a new task for a testbox.
+        """
+        oTBStatusLogic = TestBoxStatusLogic(oDb);
+
         try:
             #
@@ -1298,5 +1352,5 @@
             # related to our scheduling queue.  Also, since this is a very
             # expensive operation we lock the testbox status row to fend of
-            # repeated retires by fault testbox script.
+            # repeated retires by faulty testbox scripts.
             #
             tsSecStart = utils.timestampSecond();
@@ -1305,26 +1359,32 @@
             oDb.execute('SELECT idTestBox FROM TestBoxStatuses WHERE idTestBox = %s FOR UPDATE NOWAIT'
                         % (oTestBoxData.idTestBox,));
-            oDb.execute('SELECT idSchedGroup FROM SchedQueues WHERE idSchedGroup = %s FOR UPDATE'
-                        % (oTestBoxData.idSchedGroup,));
+            oDb.execute('SELECT SchedQueues.idSchedGroup\n'
+                        '  FROM SchedQueues, TestBoxesInSchedGroups\n'
+                        'WHERE  TestBoxesInSchedGroups.idTestBox    = %s\n'
+                        '   AND TestBoxesInSchedGroups.tsExpire     = \'infinity\'::TIMESTAMP\n'
+                        '   AND TestBoxesInSchedGroups.idSchedGroup = SchedQueues.idSchedGroup\n'
+                        ' FOR UPDATE'
+                        % (oTestBoxData.idTestBox,));
 
             # We need the current timestamp.
             tsNow = oDb.getCurrentTimestamp();
 
-            # Re-read the testbox data ...
-            oTestBoxDataCur = TestBoxData().initFromDbWithId(oDb, oTestBoxData.idTestBox, tsNow);
-            if    oTestBoxDataCur.fEnabled \
-              and oTestBoxDataCur.idGenTestBox == oTestBoxData.idGenTestBox \
-              and oTestBoxDataCur.idSchedGroup == oTestBoxData.idSchedGroup: # (paranoia wrt idSchedGroup)
-
-                # ... and schedule group data.
-                oSchedGrpData = SchedGroupData().initFromDbWithId(oDb, oTestBoxDataCur.idSchedGroup, tsNow);
-                if oSchedGrpData.fEnabled  and  oSchedGrpData.idBuildSrc is not None:
+            # Re-read the testbox data with scheduling group relations.
+            oTestBoxDataEx = TestBoxDataEx().initFromDbWithId(oDb, oTestBoxData.idTestBox, tsNow);
+            if    oTestBoxDataEx.fEnabled \
+              and oTestBoxDataEx.idGenTestBox == oTestBoxData.idGenTestBox:
+
+                # Now, pick the scheduling group.
+                (oSchedGroup, iWorkItem) = SchedulerBase._pickSchedGroup(oTestBoxDataEx, iWorkItem);
+                if oSchedGroup is not None:
+                    assert oSchedGroup.fEnabled and oSchedGroup.idBuildSrc is not None;
 
                     #
                     # Instantiate the specified scheduler and let it do the rest.
                     #
-                    oScheduler = SchedulerBase._instantiate(oDb, oSchedGrpData, iVerbosity, tsSecStart);
-                    dResponse = oScheduler.scheduleNewTaskWorker(oTestBoxDataCur, tsNow, sBaseUrl);
+                    oScheduler = SchedulerBase._instantiate(oDb, oSchedGroup, iVerbosity, tsSecStart);
+                    dResponse = oScheduler.scheduleNewTaskWorker(oTestBoxDataEx, tsNow, sBaseUrl);
                     if dResponse is not None:
+                        oTBStatusLogic.updateWorkItem(oTestBoxDataEx.idTestBox, iWorkItem);
                         oDb.commit();
                         return dResponse;
Index: /trunk/src/VBox/ValidationKit/testmanager/core/testbox.pgsql
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/testbox.pgsql	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/testbox.pgsql	(revision 61502)
@@ -26,85 +26,8 @@
 
 
----
--- Checks if the test box name is unique, ignoring a_idTestCaseIgnore.
--- Raises exception if duplicates are found.
---
--- @internal
---
-CREATE OR REPLACE FUNCTION TestBoxLogic_checkUniqueName(a_sName TEXT, a_idTestBoxIgnore INTEGER)
-    RETURNS VOID AS $$
-    DECLARE
-        v_cRows INTEGER;
-    BEGIN
-        SELECT  COUNT(*) INTO v_cRows
-        FROM    TestBoxes
-        WHERE   sName      =  a_sName
-            AND tsExpire   =  'infinity'::TIMESTAMP
-            AND idTestBox <> a_idTestBoxIgnore;
-        IF v_cRows <> 0 THEN
-            RAISE EXCEPTION 'Duplicate test box name "%" (% times)', a_sName, v_cRows;
-        END IF;
-    END;
-$$ LANGUAGE plpgsql;
-
-
----
--- Historize a row.
--- @internal
---
-CREATE OR REPLACE FUNCTION TestBoxLogic_historizeEntry(a_idGenTestBox INTEGER, a_tsExpire TIMESTAMP WITH TIME ZONE)
-    RETURNS VOID AS $$
-    DECLARE
-        v_cUpdatedRows INTEGER;
-    BEGIN
-        UPDATE  TestBoxes
-          SET   tsExpire        = a_tsExpire
-          WHERE idGenTestBox    = a_idGenTestBox
-            AND tsExpire        = 'infinity'::TIMESTAMP;
-        GET DIAGNOSTICS v_cUpdatedRows = ROW_COUNT;
-        IF v_cUpdatedRows <> 1 THEN
-            IF v_cUpdatedRows = 0 THEN
-                RAISE EXCEPTION 'Test box generation ID % is no longer valid', a_idGenTestBox;
-            END IF;
-            RAISE EXCEPTION 'Integrity error in TestBoxes: % current rows with idGenTestBox=%', v_cUpdatedRows, a_idGenTestBox;
-        END IF;
-    END;
-$$ LANGUAGE plpgsql;
-
-
----
--- Translate string via the string table.
---
--- @returns NULL if a_sValue is NULL, otherwise a string ID.
---
-CREATE OR REPLACE FUNCTION TestBoxLogic_lookupOrFindString(a_sValue TEXT)
-    RETURNS INTEGER AS $$
-    DECLARE
-        v_idStr        INTEGER;
-        v_cRows        INTEGER;
-    BEGIN
-        IF a_sValue IS NULL THEN
-            RETURN NULL;
-        END IF;
-
-        SELECT      idStr
-            INTO    v_idStr
-            FROM    TestBoxStrTab
-            WHERE   sValue = a_sValue;
-        GET DIAGNOSTICS v_cRows = ROW_COUNT;
-        IF v_cRows = 0 THEN
-            INSERT INTO TestBoxStrTab (sValue)
-                VALUES (a_sValue)
-                RETURNING idStr INTO v_idStr;
-        END IF;
-        RETURN v_idStr;
-    END;
-$$ LANGUAGE plpgsql;
-
-
----
--- Only adds the user settable parts of the row, i.e. not what TestBoxLogic_updateOnSignOn touches.
---
-CREATE OR REPLACE function TestBoxLogic_addEntry(a_uidAuthor            INTEGER,
+--
+-- Old type signatures.
+--
+DROP FUNCTION IF EXISTS TestBoxLogic_addEntry(a_uidAuthor            INTEGER,
                                                  a_ip                   inet,
                                                  a_uuidSystem           uuid,
@@ -120,50 +43,6 @@
                                                  OUT r_idTestBox        INTEGER,
                                                  OUT r_idGenTestBox     INTEGER,
-                                                 OUT r_tsEffective      TIMESTAMP WITH TIME ZONE
-                                                 ) AS $$
-    DECLARE
-         v_idStrDescription INTEGER;
-         v_idStrComment     INTEGER;
-    BEGIN
-        PERFORM TestBoxLogic_checkUniqueName(a_sName, -1);
-
-        SELECT TestBoxLogic_lookupOrFindString(a_sDescription) INTO v_idStrDescription;
-        SELECT TestBoxLogic_lookupOrFindString(a_sComment)     INTO v_idStrComment;
-
-        INSERT INTO TestBoxes (
-                    tsEffective,         -- 1
-                    uidAuthor,           -- 2
-                    ip,                  -- 3
-                    uuidSystem,          -- 4
-                    sName,               -- 5
-                    idStrDescription,    -- 6
-                    idSchedGroup,        -- 7
-                    fEnabled,            -- 8
-                    enmLomKind,          -- 9
-                    ipLom,               -- 10
-                    pctScaleTimeout,     -- 11
-                    idStrComment,        -- 12
-                    enmPendingCmd )      -- 13
-            VALUES (CURRENT_TIMESTAMP,   -- 1
-                    a_uidAuthor,         -- 2
-                    a_ip,                -- 3
-                    a_uuidSystem,        -- 4
-                    a_sName,             -- 5
-                    v_idStrDescription,  -- 6
-                    a_idSchedGroup,      -- 7
-                    a_fEnabled,          -- 8
-                    a_enmLomKind,        -- 9
-                    a_ipLom,             -- 10
-                    a_pctScaleTimeout,   -- 11
-                    v_idStrComment,      -- 12
-                    a_enmPendingCmd )    -- 13
-            RETURNING idTestBox, idGenTestBox, tsEffective INTO r_idTestBox, r_idGenTestBox, r_tsEffective;
-    END;
-$$ LANGUAGE plpgsql;
-
----
--- Only adds the user settable parts of the row, i.e. not what TestBoxLogic_updateOnSignOn touches.
---
-CREATE OR REPLACE function TestBoxLogic_editEntry(a_uidAuthor           INTEGER,
+                                                 OUT r_tsEffective      TIMESTAMP WITH TIME ZONE);
+DROP FUNCTION IF EXISTS TestBoxLogic_editEntry(a_uidAuthor           INTEGER,
                                                   a_idTestBox           INTEGER,
                                                   a_ip                  inet,
@@ -179,4 +58,272 @@
                                                   a_enmPendingCmd       TestBoxCmd_T,
                                                   OUT r_idGenTestBox    INTEGER,
+                                                  OUT r_tsEffective     TIMESTAMP WITH TIME ZONE);
+DROP FUNCTION IF EXISTS TestBoxLogic_removeEntry(INTEGER, INTEGER, BOOLEAN);
+DROP FUNCTION IF EXISTS TestBoxLogic_addGroupEntry(a_uidAuthor           INTEGER,
+                                                   a_idTestBox           INTEGER,
+                                                   a_idSchedGroup        INTEGER,
+                                                   a_iSchedPriority      INTEGER,
+                                                   OUT r_tsEffective     TIMESTAMP WITH TIME ZONE);
+DROP FUNCTION IF EXISTS    TestBoxLogic_editGroupEntry(a_uidAuthor          INTEGER,
+                                                       a_idTestBox          INTEGER,
+                                                       a_idSchedGroup       INTEGER,
+                                                       a_iSchedPriority     INTEGER,
+                                                       OUT r_tsEffective    INTEGER);
+
+
+---
+-- Checks if the test box name is unique, ignoring a_idTestCaseIgnore.
+-- Raises exception if duplicates are found.
+--
+-- @internal
+--
+CREATE OR REPLACE FUNCTION TestBoxLogic_checkUniqueName(a_sName TEXT, a_idTestBoxIgnore INTEGER)
+    RETURNS VOID AS $$
+    DECLARE
+        v_cRows INTEGER;
+    BEGIN
+        SELECT  COUNT(*) INTO v_cRows
+        FROM    TestBoxes
+        WHERE   sName      =  a_sName
+            AND tsExpire   =  'infinity'::TIMESTAMP
+            AND idTestBox <> a_idTestBoxIgnore;
+        IF v_cRows <> 0 THEN
+            RAISE EXCEPTION 'Duplicate test box name "%" (% times)', a_sName, v_cRows;
+        END IF;
+    END;
+$$ LANGUAGE plpgsql;
+
+
+---
+-- Checks that the given scheduling group exists.
+-- Raises exception if it doesn't.
+--
+-- @internal
+--
+CREATE OR REPLACE FUNCTION TestBoxLogic_checkSchedGroupExists(a_idSchedGroup INTEGER)
+    RETURNS VOID AS $$
+    DECLARE
+        v_cRows INTEGER;
+    BEGIN
+        SELECT  COUNT(*) INTO v_cRows
+        FROM    SchedGroups
+        WHERE   idSchedGroup = a_idSchedGroup
+            AND tsExpire     =  'infinity'::TIMESTAMP;
+        IF v_cRows <> 1 THEN
+            IF v_cRows = 0 THEN
+                RAISE EXCEPTION 'Scheduling group with ID % was not found', a_idSchedGroup;
+            END IF;
+            RAISE EXCEPTION 'Integrity error in SchedGroups: % current rows with idSchedGroup=%', v_cRows, a_idSchedGroup;
+        END IF;
+    END;
+$$ LANGUAGE plpgsql;
+
+
+---
+-- Checks that the given testbxo + scheduling group pair does not currently exists.
+-- Raises exception if it does.
+--
+-- @internal
+--
+CREATE OR REPLACE FUNCTION TestBoxLogic_checkTestBoxNotInSchedGroup(a_idTestBox INTEGER, a_idSchedGroup INTEGER)
+    RETURNS VOID AS $$
+    DECLARE
+        v_cRows INTEGER;
+    BEGIN
+        SELECT  COUNT(*) INTO v_cRows
+        FROM    TestBoxesInSchedGroups
+        WHERE   idTestBox    = a_idTestBox
+            AND idSchedGroup = a_idSchedGroup
+            AND tsExpire     =  'infinity'::TIMESTAMP;
+        IF v_cRows <> 0 THEN
+            RAISE EXCEPTION 'TestBox % is already a member of scheduling group %', a_idTestBox, a_idSchedGroup;
+        END IF;
+    END;
+$$ LANGUAGE plpgsql;
+
+
+---
+-- Historize a row.
+-- @internal
+--
+CREATE OR REPLACE FUNCTION TestBoxLogic_historizeEntry(a_idGenTestBox INTEGER, a_tsExpire TIMESTAMP WITH TIME ZONE)
+    RETURNS VOID AS $$
+    DECLARE
+        v_cUpdatedRows INTEGER;
+    BEGIN
+        UPDATE  TestBoxes
+          SET   tsExpire        = a_tsExpire
+          WHERE idGenTestBox    = a_idGenTestBox
+            AND tsExpire        = 'infinity'::TIMESTAMP;
+        GET DIAGNOSTICS v_cUpdatedRows = ROW_COUNT;
+        IF v_cUpdatedRows <> 1 THEN
+            IF v_cUpdatedRows = 0 THEN
+                RAISE EXCEPTION 'Test box generation ID % is no longer valid', a_idGenTestBox;
+            END IF;
+            RAISE EXCEPTION 'Integrity error in TestBoxes: % current rows with idGenTestBox=%', v_cUpdatedRows, a_idGenTestBox;
+        END IF;
+    END;
+$$ LANGUAGE plpgsql;
+
+
+---
+-- Historize a in-scheduling-group row.
+-- @internal
+--
+CREATE OR REPLACE FUNCTION TestBoxLogic_historizeGroupEntry(a_idTestBox INTEGER,
+                                                            a_idSchedGroup INTEGER,
+                                                            a_tsExpire TIMESTAMP WITH TIME ZONE)
+    RETURNS VOID AS $$
+    DECLARE
+        v_cUpdatedRows INTEGER;
+    BEGIN
+        UPDATE  TestBoxesInSchedGroups
+          SET   tsExpire        = a_tsExpire
+          WHERE idTestBox       = a_idTestBox
+            AND idSchedGroup    = a_idSchedGroup
+            AND tsExpire        = 'infinity'::TIMESTAMP;
+        GET DIAGNOSTICS v_cUpdatedRows = ROW_COUNT;
+        IF v_cUpdatedRows <> 1 THEN
+            IF v_cUpdatedRows = 0 THEN
+                RAISE EXCEPTION 'TestBox ID % / SchedGroup ID % is no longer a valid combination', a_idTestBox, a_idSchedGroup;
+            END IF;
+            RAISE EXCEPTION 'Integrity error in TestBoxesInSchedGroups: % current rows for % / %',
+                v_cUpdatedRows, a_idTestBox, a_idSchedGroup;
+        END IF;
+    END;
+$$ LANGUAGE plpgsql;
+
+
+---
+-- Translate string via the string table.
+--
+-- @returns NULL if a_sValue is NULL, otherwise a string ID.
+--
+CREATE OR REPLACE FUNCTION TestBoxLogic_lookupOrFindString(a_sValue TEXT)
+    RETURNS INTEGER AS $$
+    DECLARE
+        v_idStr        INTEGER;
+        v_cRows        INTEGER;
+    BEGIN
+        IF a_sValue IS NULL THEN
+            RETURN NULL;
+        END IF;
+
+        SELECT      idStr
+            INTO    v_idStr
+            FROM    TestBoxStrTab
+            WHERE   sValue = a_sValue;
+        GET DIAGNOSTICS v_cRows = ROW_COUNT;
+        IF v_cRows = 0 THEN
+            INSERT INTO TestBoxStrTab (sValue)
+                VALUES (a_sValue)
+                RETURNING idStr INTO v_idStr;
+        END IF;
+        RETURN v_idStr;
+    END;
+$$ LANGUAGE plpgsql;
+
+
+---
+-- Only adds the user settable parts of the row, i.e. not what TestBoxLogic_updateOnSignOn touches.
+--
+CREATE OR REPLACE function TestBoxLogic_addEntry(a_uidAuthor            INTEGER,
+                                                 a_ip                   inet,
+                                                 a_uuidSystem           uuid,
+                                                 a_sName                TEXT,
+                                                 a_sDescription         TEXT,
+                                                 a_fEnabled             BOOLEAN,
+                                                 a_enmLomKind           LomKind_T,
+                                                 a_ipLom                inet,
+                                                 a_pctScaleTimeout      INTEGER,  -- Actually smallint, but default typing fun.
+                                                 a_sComment             TEXT,
+                                                 a_enmPendingCmd        TestBoxCmd_T,
+                                                 OUT r_idTestBox        INTEGER,
+                                                 OUT r_idGenTestBox     INTEGER,
+                                                 OUT r_tsEffective      TIMESTAMP WITH TIME ZONE
+                                                 ) AS $$
+    DECLARE
+         v_idStrDescription INTEGER;
+         v_idStrComment     INTEGER;
+    BEGIN
+        PERFORM TestBoxLogic_checkUniqueName(a_sName, -1);
+
+        SELECT TestBoxLogic_lookupOrFindString(a_sDescription) INTO v_idStrDescription;
+        SELECT TestBoxLogic_lookupOrFindString(a_sComment)     INTO v_idStrComment;
+
+        INSERT INTO TestBoxes (
+                    tsEffective,         -- 1
+                    uidAuthor,           -- 2
+                    ip,                  -- 3
+                    uuidSystem,          -- 4
+                    sName,               -- 5
+                    idStrDescription,    -- 6
+                    fEnabled,            -- 7
+                    enmLomKind,          -- 8
+                    ipLom,               -- 9
+                    pctScaleTimeout,     -- 10
+                    idStrComment,        -- 11
+                    enmPendingCmd )      -- 12
+            VALUES (CURRENT_TIMESTAMP,   -- 1
+                    a_uidAuthor,         -- 2
+                    a_ip,                -- 3
+                    a_uuidSystem,        -- 4
+                    a_sName,             -- 5
+                    v_idStrDescription,  -- 6
+                    a_fEnabled,          -- 7
+                    a_enmLomKind,        -- 8
+                    a_ipLom,             -- 9
+                    a_pctScaleTimeout,   -- 10
+                    v_idStrComment,      -- 11
+                    a_enmPendingCmd )    -- 12
+            RETURNING idTestBox, idGenTestBox, tsEffective INTO r_idTestBox, r_idGenTestBox, r_tsEffective;
+    END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OR REPLACE function TestBoxLogic_addGroupEntry(a_uidAuthor           INTEGER,
+                                                      a_idTestBox           INTEGER,
+                                                      a_idSchedGroup        INTEGER,
+                                                      a_iSchedPriority      INTEGER,
+                                                      OUT r_tsEffective     TIMESTAMP WITH TIME ZONE
+                                                      ) AS $$
+    BEGIN
+        PERFORM TestBoxLogic_checkSchedGroupExists(a_idSchedGroup);
+        PERFORM TestBoxLogic_checkTestBoxNotInSchedGroup(a_idTestBox, a_idSchedGroup);
+
+        INSERT INTO TestBoxesInSchedGroups (
+                    idTestBox,
+                    idSchedGroup,
+                    tsEffective,
+                    tsExpire,
+                    uidAuthor,
+                    iSchedPriority)
+            VALUES (a_idTestBox,
+                    a_idSchedGroup,
+                    CURRENT_TIMESTAMP,
+                    'infinity'::TIMESTAMP,
+                    a_uidAuthor,
+                    a_iSchedPriority)
+            RETURNING tsEffective INTO r_tsEffective;
+    END;
+$$ LANGUAGE plpgsql;
+
+
+---
+-- Only adds the user settable parts of the row, i.e. not what TestBoxLogic_updateOnSignOn touches.
+--
+CREATE OR REPLACE function TestBoxLogic_editEntry(a_uidAuthor           INTEGER,
+                                                  a_idTestBox           INTEGER,
+                                                  a_ip                  inet,
+                                                  a_uuidSystem          uuid,
+                                                  a_sName               TEXT,
+                                                  a_sDescription        TEXT,
+                                                  a_fEnabled            BOOLEAN,
+                                                  a_enmLomKind          LomKind_T,
+                                                  a_ipLom               inet,
+                                                  a_pctScaleTimeout     INTEGER, -- Actually smallint, but default typing fun.
+                                                  a_sComment            TEXT,
+                                                  a_enmPendingCmd       TestBoxCmd_T,
+                                                  OUT r_idGenTestBox    INTEGER,
                                                   OUT r_tsEffective     TIMESTAMP WITH TIME ZONE
                                                   ) AS $$
@@ -204,5 +351,4 @@
         v_Row.sName             := a_sName;
         v_Row.idStrDescription  := v_idStrDescription;
-        v_Row.idSchedGroup      := a_idSchedGroup;
         v_Row.fEnabled          := a_fEnabled;
         v_Row.enmLomKind        := a_enmLomKind;
@@ -225,5 +371,38 @@
 
 
-DROP FUNCTION IF EXISTS TestBoxLogic_removeEntry(INTEGER, INTEGER, BOOLEAN);
+CREATE OR REPLACE function TestBoxLogic_editGroupEntry(a_uidAuthor          INTEGER,
+                                                       a_idTestBox          INTEGER,
+                                                       a_idSchedGroup       INTEGER,
+                                                       a_iSchedPriority     INTEGER,
+                                                       OUT r_tsEffective    TIMESTAMP WITH TIME ZONE
+                                                       ) AS $$
+    DECLARE
+        v_Row               TestBoxesInSchedGroups%ROWTYPE;
+        v_idStrDescription  INTEGER;
+        v_idStrComment      INTEGER;
+    BEGIN
+        PERFORM TestBoxLogic_checkSchedGroupExists(a_idSchedGroup);
+
+        -- Fetch and historize the current row - there must be one.
+        UPDATE      TestBoxesInSchedGroups
+            SET     tsExpire     = CURRENT_TIMESTAMP
+            WHERE   idTestBox    = a_idTestBox
+                AND idSchedGroup = a_idSchedGroup
+                AND tsExpire     = 'infinity'::TIMESTAMP
+            RETURNING * INTO STRICT v_Row;
+
+        -- Modify the row with the new data.
+        v_Row.uidAuthor         := a_uidAuthor;
+        v_Row.iSchedPriority    := a_iSchedPriority;
+        v_Row.tsEffective       := v_Row.tsExpire;
+        r_tsEffective           := v_Row.tsExpire;
+        v_Row.tsExpire          := 'infinity'::TIMESTAMP;
+
+        -- Insert the modified row.
+        INSERT INTO TestBoxesInSchedGroups VALUES (v_Row.*);
+    END;
+$$ LANGUAGE plpgsql;
+
+
 CREATE OR REPLACE FUNCTION TestBoxLogic_removeEntry(a_uidAuthor INTEGER, a_idTestBox INTEGER, a_fCascade BOOLEAN)
     RETURNS VOID AS $$
@@ -242,4 +421,12 @@
             RAISE EXCEPTION 'CASCADE test box deletion is not implemented';
         END IF;
+
+        --
+        -- Delete all current groups, skipping history since we're also deleting the testbox.
+        --
+        UPDATE      TestBoxesInSchedGroups
+            SET     tsExpire = CURRENT_TIMESTAMP
+            WHERE   idTestBox   = a_idTestBox
+                AND tsExpire    = 'infinity'::TIMESTAMP;
 
         --
@@ -271,4 +458,43 @@
         WHEN TOO_MANY_ROWS THEN
             RAISE EXCEPTION 'Integrity error in TestBoxes: Too many current rows for %', a_idTestBox;
+    END;
+$$ LANGUAGE plpgsql;
+
+
+CREATE OR REPLACE FUNCTION TestBoxLogic_removeGroupEntry(a_uidAuthor INTEGER, a_idTestBox INTEGER, a_idSchedGroup INTEGER)
+    RETURNS VOID AS $$
+    DECLARE
+        v_Row           TestBoxesInSchedGroups%ROWTYPE;
+        v_tsEffective   TIMESTAMP WITH TIME ZONE;
+    BEGIN
+        --
+        -- To preserve the information about who deleted the record, we try to
+        -- add a dummy record which expires immediately.  I say try because of
+        -- the primary key, we must let the new record be valid for 1 us. :-(
+        --
+        SELECT  * INTO STRICT v_Row
+        FROM    TestBoxesInSchedGroups
+        WHERE   idTestBox    = a_idTestBox
+            AND idSchedGroup = a_idSchedGroup
+            AND tsExpire     = 'infinity'::TIMESTAMP;
+
+        v_tsEffective := CURRENT_TIMESTAMP - INTERVAL '1 microsecond';
+        IF v_Row.tsEffective < v_tsEffective THEN
+            PERFORM TestBoxLogic_historizeGroupEntry(a_idTestBox, a_idSchedGroup, v_tsEffective);
+
+            v_Row.tsEffective   := v_tsEffective;
+            v_Row.tsExpire      := CURRENT_TIMESTAMP;
+            v_Row.uidAuthor     := a_uidAuthor;
+            INSERT INTO TestBoxesInSchedGroups VALUES (v_Row.*);
+        ELSE
+            PERFORM TestBoxLogic_historizeGroupEntry(a_idTestBox, a_idSchedGroup, CURRENT_TIMESTAMP);
+        END IF;
+
+    EXCEPTION
+        WHEN NO_DATA_FOUND THEN
+            RAISE EXCEPTION 'TestBox #% does is not currently a member of scheduling group #%', a_idTestBox, a_idSchedGroup;
+        WHEN TOO_MANY_ROWS THEN
+            RAISE EXCEPTION 'Integrity error in TestBoxesInSchedGroups: Too many current rows for % / %',
+                a_idTestBox, a_idSchedGroup;
     END;
 $$ LANGUAGE plpgsql;
Index: /trunk/src/VBox/ValidationKit/testmanager/core/testbox.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/testbox.py	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/testbox.py	(revision 61502)
@@ -31,10 +31,77 @@
 
 # Standard python imports.
+import copy;
 import unittest;
 
 # Validation Kit imports.
+from testmanager.core               import db;
 from testmanager.core.base          import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, TMInFligthCollision, \
-                                           TMInvalidData, TMTooManyRows, TMRowNotFound, ChangeLogEntry, AttributeChangeEntry;
+                                           TMInvalidData, TMTooManyRows, TMRowNotFound, \
+                                           ChangeLogEntry, AttributeChangeEntry;
 from testmanager.core.useraccount   import UserAccountLogic;
+
+
+class TestBoxInSchedGroupData(ModelDataBase):
+    """
+    TestBox in SchedGroup data.
+    """
+
+    ksParam_idTestBox           = 'TestBoxInSchedGroup_idTestBox';
+    ksParam_idSchedGroup        = 'TestBoxInSchedGroup_idSchedGroup';
+    ksParam_tsEffective         = 'TestBoxInSchedGroup_tsEffective';
+    ksParam_tsExpire            = 'TestBoxInSchedGroup_tsExpire';
+    ksParam_uidAuthor           = 'TestBoxInSchedGroup_uidAuthor';
+    ksParam_iSchedPriority      = 'TestBoxInSchedGroup_iSchedPriority';
+
+    kasAllowNullAttributes      = [ 'idTestBox', 'tsEffective', 'tsExpire', 'uidAuthor', ]
+
+    kiMin_iSchedPriority        = 0;
+    kiMax_iSchedPriority        = 32;
+
+    kcDbColumns                 = 6;
+
+    def __init__(self):
+        ModelDataBase.__init__(self);
+        self.idTestBox          = None;
+        self.idSchedGroup       = None;
+        self.tsEffective        = None;
+        self.tsExpire           = None;
+        self.uidAuthor          = None;
+        self.iSchedPriority     = 16;
+
+    def initFromDbRow(self, aoRow):
+        """
+        Expecting the result from a query like this:
+            SELECT * FROM TestBoxesInSchedGroups
+        """
+        if aoRow is None:
+            raise TMRowNotFound('TestBox/SchedGroup not found.');
+
+        self.idTestBox          = aoRow[0];
+        self.idSchedGroup       = aoRow[1];
+        self.tsEffective        = aoRow[2];
+        self.tsExpire           = aoRow[3];
+        self.uidAuthor          = aoRow[4];
+        self.iSchedPriority     = aoRow[5];
+
+        return self;
+
+class TestBoxInSchedGroupDataEx(TestBoxInSchedGroupData):
+    """
+    Extended version of TestBoxInSchedGroupData that contains the scheduling group.
+    """
+
+    def __init__(self):
+        TestBoxInSchedGroupData.__init__(self);
+        self.oSchedGroup        = None; # type: SchedGroupData
+
+    def initFromDbRowEx(self, aoRow, oDb, tsNow = None, sPeriodBack = None):
+        """
+        Extended version of initFromDbRow that fills in the rest from the database.
+        """
+        from testmanager.core.schedgroup import SchedGroupData;
+        self.initFromDbRow(aoRow);
+        self.oSchedGroup        = SchedGroupData().initFromDbWithId(oDb, self.idSchedGroup, tsNow, sPeriodBack);
+        return self;
 
 
@@ -92,5 +159,4 @@
     ksParam_sName               = 'TestBox_sName';
     ksParam_sDescription        = 'TestBox_sDescription';
-    ksParam_idSchedGroup        = 'TestBox_idSchedGroup';
     ksParam_fEnabled            = 'TestBox_fEnabled';
     ksParam_enmLomKind          = 'TestBox_enmLomKind';
@@ -119,9 +185,9 @@
     kasInternalAttributes       = [ 'idStrDescription', 'idStrComment', 'idStrOs', 'idStrOsVersion', 'idStrCpuVendor',
                                     'idStrCpuArch', 'idStrCpuName', 'idStrReport', ];
+    kasMachineSettableOnly      = [ 'sOs', 'sOsVersion', 'sCpuVendor', 'sCpuArch', 'sCpuName', 'lCpuRevision', 'cCpus',
+                                    'fCpuHwVirt', 'fCpuNestedPaging', 'fCpu64BitGuest', 'fChipsetIoMmu', 'fRawMode',
+                                    'cMbMemory', 'cMbScratch', 'sReport', 'iTestBoxScriptRev', 'iPythonHexVersion', ];
     kasAllowNullAttributes      = ['idTestBox', 'tsEffective', 'tsExpire', 'uidAuthor', 'idGenTestBox', 'sDescription',
-                                   'ipLom', 'sComment', 'sOs', 'sOsVersion', 'sCpuVendor', 'sCpuArch', 'sCpuName',
-                                   'lCpuRevision', 'cCpus', 'fCpuHwVirt', 'fCpuNestedPaging', 'fCpu64BitGuest', 'fChipsetIoMmu',
-                                   'fRawMode', 'cMbMemory', 'cMbScratch', 'sReport', 'iTestBoxScriptRev', 'iPythonHexVersion',
-                                  ] + kasInternalAttributes;
+                                   'ipLom', 'sComment', ] + kasMachineSettableOnly + kasInternalAttributes;
 
     kasValidValues_enmLomKind   = kasLomKindValues;
@@ -131,6 +197,6 @@
     kcchMax_sReport             = 65535;
 
-
-    kcDbColumns                 = 41; # including the 7 string joins columns
+    kcDbColumns                 = 40; # including the 7 string joins columns
+
 
     def __init__(self):
@@ -150,5 +216,4 @@
         self.sName               = None;
         self.idStrDescription    = None;
-        self.idSchedGroup        = 1;
         self.fEnabled            = False;
         self.enmLomKind          = self.ksLomKind_None;
@@ -202,39 +267,38 @@
         self.sName               = aoRow[7];
         self.idStrDescription    = aoRow[8];
-        self.idSchedGroup        = aoRow[9];
-        self.fEnabled            = aoRow[10];
-        self.enmLomKind          = aoRow[11];
-        self.ipLom               = aoRow[12];
-        self.pctScaleTimeout     = aoRow[13];
-        self.idStrComment        = aoRow[14];
-        self.idStrOs             = aoRow[15];
-        self.idStrOsVersion      = aoRow[16];
-        self.idStrCpuVendor      = aoRow[17];
-        self.idStrCpuArch        = aoRow[18];
-        self.idStrCpuName        = aoRow[19];
-        self.lCpuRevision        = aoRow[20];
-        self.cCpus               = aoRow[21];
-        self.fCpuHwVirt          = aoRow[22];
-        self.fCpuNestedPaging    = aoRow[23];
-        self.fCpu64BitGuest      = aoRow[24];
-        self.fChipsetIoMmu       = aoRow[25];
-        self.fRawMode            = aoRow[26];
-        self.cMbMemory           = aoRow[27];
-        self.cMbScratch          = aoRow[28];
-        self.idStrReport         = aoRow[29];
-        self.iTestBoxScriptRev   = aoRow[30];
-        self.iPythonHexVersion   = aoRow[31];
-        self.enmPendingCmd       = aoRow[32];
+        self.fEnabled            = aoRow[9];
+        self.enmLomKind          = aoRow[10];
+        self.ipLom               = aoRow[11];
+        self.pctScaleTimeout     = aoRow[12];
+        self.idStrComment        = aoRow[13];
+        self.idStrOs             = aoRow[14];
+        self.idStrOsVersion      = aoRow[15];
+        self.idStrCpuVendor      = aoRow[16];
+        self.idStrCpuArch        = aoRow[17];
+        self.idStrCpuName        = aoRow[18];
+        self.lCpuRevision        = aoRow[19];
+        self.cCpus               = aoRow[20];
+        self.fCpuHwVirt          = aoRow[21];
+        self.fCpuNestedPaging    = aoRow[22];
+        self.fCpu64BitGuest      = aoRow[23];
+        self.fChipsetIoMmu       = aoRow[24];
+        self.fRawMode            = aoRow[25];
+        self.cMbMemory           = aoRow[26];
+        self.cMbScratch          = aoRow[27];
+        self.idStrReport         = aoRow[28];
+        self.iTestBoxScriptRev   = aoRow[29];
+        self.iPythonHexVersion   = aoRow[30];
+        self.enmPendingCmd       = aoRow[31];
 
         # String table values.
         if len(aoRow) > 32:
-            self.sDescription    = aoRow[33];
-            self.sComment        = aoRow[34];
-            self.sOs             = aoRow[35];
-            self.sOsVersion      = aoRow[36];
-            self.sCpuVendor      = aoRow[37];
-            self.sCpuArch        = aoRow[38];
-            self.sCpuName        = aoRow[39];
-            self.sReport         = aoRow[40];
+            self.sDescription    = aoRow[32];
+            self.sComment        = aoRow[33];
+            self.sOs             = aoRow[34];
+            self.sOsVersion      = aoRow[35];
+            self.sCpuVendor      = aoRow[36];
+            self.sCpuArch        = aoRow[37];
+            self.sCpuName        = aoRow[38];
+            self.sReport         = aoRow[39];
 
         return self;
@@ -254,8 +318,9 @@
         return self.initFromDbRow(aoRow);
 
-    def initFromDbWithGenId(self, oDb, idGenTestBox):
+    def initFromDbWithGenId(self, oDb, idGenTestBox, tsNow = None):
         """
         Initialize the object from the database.
         """
+        _ = tsNow;                      # Only useful for extended data classes.
         oDb.execute('SELECT TestBoxesWithStrings.*\n'
                     'FROM   TestBoxesWithStrings\n'
@@ -444,4 +509,127 @@
 
 
+class TestBoxDataEx(TestBoxData):
+    """
+    TestBox data.
+    """
+
+    ksParam_aoInSchedGroups = 'TestBox_aoInSchedGroups';
+
+    # Use [] instead of None.
+    kasAltArrayNull = [ 'aoInSchedGroups', ];
+
+    ## Helper parameter containing the comma separated list with the IDs of
+    #  potential members found in the parameters.
+    ksParam_aidSchedGroups = 'TestBoxDataEx_aidSchedGroups';
+
+    def __init__(self):
+        TestBoxData.__init__(self);
+        self.aoInSchedGroups        = [];   # type: list[TestBoxInSchedGroupData]
+
+    def _initExtraMembersFromDb(self, oDb, tsNow = None, sPeriodBack = None):
+        """
+        Worker shared by the initFromDb* methods.
+        Returns self.  Raises exception if no row or database error.
+        """
+        oDb.execute(self.formatSimpleNowAndPeriodQuery(oDb,
+                                                       'SELECT  *\n'
+                                                       'FROM    TestBoxesInSchedGroups\n'
+                                                       'WHERE   idTestBox = %s\n'
+                                                       , (self.idTestBox,), tsNow, sPeriodBack)
+                    + 'ORDER BY idSchedGroup\n' );
+        self.aoInSchedGroups = [];
+        for aoRow in oDb.fetchAll():
+            self.aoInSchedGroups.append(TestBoxInSchedGroupDataEx().initFromDbRowEx(aoRow, oDb, tsNow, sPeriodBack));
+        return self;
+
+    def initFromDbRowEx(self, aoRow, oDb, tsNow = None):
+        """
+        Reinitialize from a SELECT * FROM TestBoxesWithStrings row.  Will query the
+        necessary additional data from oDb using tsNow.
+        Returns self.  Raises exception if no row or database error.
+        """
+        TestBoxData.initFromDbRow(self, aoRow);
+        return self._initExtraMembersFromDb(oDb, tsNow);
+
+    def initFromDbWithId(self, oDb, idTestBox, tsNow = None, sPeriodBack = None):
+        """
+        Initialize the object from the database.
+        """
+        TestBoxData.initFromDbWithId(self, oDb, idTestBox, tsNow, sPeriodBack);
+        return self._initExtraMembersFromDb(oDb, tsNow, sPeriodBack);
+
+    def initFromDbWithGenId(self, oDb, idGenTestBox, tsNow = None):
+        """
+        Initialize the object from the database.
+        """
+        TestBoxData.initFromDbWithGenId(self, oDb, idGenTestBox);
+        if tsNow is None and not oDb.isTsInfinity(self.tsExpire):
+            tsNow = self.tsEffective;
+        return self._initExtraMembersFromDb(oDb, tsNow);
+
+    def getAttributeParamNullValues(self, sAttr): # Necessary?
+        if sAttr in ['aoInSchedGroups', ]:
+            return [[], ''];
+        return TestBoxData.getAttributeParamNullValues(self, sAttr);
+
+    def convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict):
+        """
+        For dealing with the in-scheduling-group list.
+        """
+        if sAttr != 'aoInSchedGroups':
+            return TestBoxData.convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict);
+
+        aoNewValues = [];
+        aidSelected = oDisp.getListOfIntParams(sParam, iMin = 1, iMax = 0x7ffffffe, aiDefaults = []);
+        asIds       = oDisp.getStringParam(self.ksParam_aidSchedGroups, sDefault = '').split(',');
+        for idSchedGroup in asIds:
+            try:    idSchedGroup = int(idSchedGroup);
+            except: pass;
+            oDispWrapper = self.DispWrapper(oDisp, '%s[%s][%%s]' % (TestBoxDataEx.ksParam_aoInSchedGroups, idSchedGroup,))
+            oMember = TestBoxInSchedGroupData().initFromParams(oDispWrapper, fStrict = False);
+            if idSchedGroup in aidSelected:
+                aoNewValues.append(oMember);
+        return aoNewValues;
+
+    def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb): # pylint: disable=R0914
+        """
+        Validate special arrays and requirement expressions.
+
+        Some special needs for the in-scheduling-group list.
+        """
+        if sAttr != 'aoInSchedGroups':
+            return TestBoxData._validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb);
+
+        asErrors = [];
+        aoNewValues = [];
+
+        # Note! We'll be returning an error dictionary instead of an string here.
+        dErrors = {};
+
+        for iInGrp, oInSchedGroup in enumerate(self.aoInSchedGroups):
+            oInSchedGroup = copy.copy(oInSchedGroup);
+            oInSchedGroup.idTestBox = self.idTestBox;
+            dCurErrors = oInSchedGroup.validateAndConvert(oDb, ModelDataBase.ksValidateFor_Other);
+            if len(dCurErrors) == 0:
+                pass; ## @todo figure out the ID?
+            else:
+                asErrors = [];
+                for sKey in dCurErrors:
+                    asErrors.append('%s: %s' % (sKey[len('TestBoxInSchedGroup_'):], dCurErrors[sKey]));
+                dErrors[iInGrp] = '<br>\n'.join(asErrors)
+            aoNewValues.append(oInSchedGroup);
+
+        for iInGrp, oInSchedGroup in enumerate(self.aoInSchedGroups):
+            for iInGrp2 in xrange(iInGrp + 1, len(self.aoInSchedGroups)):
+                if self.aoInSchedGroups[iInGrp2].idSchedGroup == oInSchedGroup.idSchedGroup:
+                    sMsg = 'Duplicate scheduling group #%s".' % (oInSchedGroup.idSchedGroup,);
+                    if iInGrp in dErrors:   dErrors[iInGrp]  += '<br>\n' + sMsg;
+                    else:                   dErrors[iInGrp]   = sMsg;
+                    if iInGrp2 in dErrors:  dErrors[iInGrp2] += '<br>\n' + sMsg;
+                    else:                   dErrors[iInGrp2]  = sMsg;
+                    break;
+
+        return (aoNewValues, dErrors if len(dErrors) > 0 else None);
+
 
 class TestBoxLogic(ModelLogicBase):
@@ -484,10 +672,10 @@
         Raises exception on error.
         """
-        class TestBoxDataForListing(TestBoxData):
+        class TestBoxDataForListing(TestBoxDataEx):
             """ We add two members for the listing. """
             def __init__(self):
-                TestBoxData.__init__(self);
+                TestBoxDataEx.__init__(self);
                 self.tsCurrent = None;  # CURRENT_TIMESTAMP
-                self.oStatus   = None;  # TestBoxStatusData
+                self.oStatus   = None;  # type: TestBoxStatusData
 
         from testmanager.core.testboxstatus import TestBoxStatusData;
@@ -517,5 +705,5 @@
         aoRows = [];
         for aoOne in self._oDb.fetchAll():
-            oTestBox = TestBoxDataForListing().initFromDbRow(aoOne);
+            oTestBox = TestBoxDataForListing().initFromDbRowEx(aoOne, self._oDb, tsNow);
             oTestBox.tsCurrent = self._oDb.getCurrentTimestamp();
             if aoOne[TestBoxData.kcDbColumns] is not None:
@@ -532,4 +720,6 @@
         Raises exception on error.
         """
+
+        ## @todo calc changes to scheduler group!
 
         if tsNow is None:
@@ -570,6 +760,31 @@
         return (aoEntries, len(aoRows) > cMaxRows);
 
+    def _validateAndConvertData(self, oData, enmValidateFor):
+        # type: (TestBoxDataEx, str) -> None
+        """
+        Helper for addEntry and editEntry that validates the scheduling group IDs in
+        addtion to what's covered by the default validateAndConvert of the data object.
+
+        Raises exception on invalid input.
+        """
+        dDataErrors = oData.validateAndConvert(self._oDb, enmValidateFor);
+        if len(dDataErrors) > 0:
+            raise TMInvalidData('TestBoxLogic.addEntry: %s' % (dDataErrors,));
+        if len(oData.aoInSchedGroups):
+            sSchedGrps = ', '.join('(%s)' % oCur.idSchedGroup for oCur in oData.aoInSchedGroups);
+            self._oDb.execute('SELECT   SchedGroupIDs.idSchedGroup\n'
+                              'FROM     (VALUES ' + sSchedGrps + ' ) AS SchedGroupIDs(idSchedGroup)\n'
+                              '         LEFT OUTER JOIN SchedGroups\n'
+                              '                      ON     SchedGroupIDs.idSchedGroup = SchedGroups.idSchedGroup\n'
+                              '                         AND SchedGroups.tsExpire = \'infinity\'::TIMESTAMP\n'
+                              'WHERE    SchedGroups.idSchedGroup IS NULL\n');
+            aaoRows = self._oDb.fetchAll();
+            if len(aaoRows) > 0:
+                raise TMInvalidData('TestBoxLogic.addEntry missing scheduling groups: %s'
+                                    % (', '.join(str(aoRow[0]) for aoRow in aaoRows),));
+        return None;
 
     def addEntry(self, oData, uidAuthor, fCommit = False):
+        # type: (TestBoxDataEx, int, bool) -> (int, int, datetime.datetime)
         """
         Creates a testbox in the database.
@@ -577,8 +792,13 @@
         of the created testbox on success.  Throws error on failure.
         """
-        dDataErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Add);
-        if len(dDataErrors) > 0:
-            raise TMInvalidData('Invalid data passed to create(): %s' % (dDataErrors,));
-
+
+        #
+        # Validate. Extra work because of missing foreign key (due to history).
+        #
+        self._validateAndConvertData(oData, oData.ksValidateFor_Add);
+
+        #
+        # Do it.
+        #
         self._oDb.callProc('TestBoxLogic_addEntry'
                            , ( uidAuthor,
@@ -587,5 +807,4 @@
                                oData.sName,
                                oData.sDescription,
-                               oData.idSchedGroup,
                                oData.fEnabled,
                                oData.enmLomKind,
@@ -594,7 +813,12 @@
                                oData.sComment,
                                oData.enmPendingCmd, ) );
-        aoRow = self._oDb.fetchOne();
+        (idTestBox, idGenTestBox, tsEffective) = self._oDb.fetchOne();
+
+        for oInSchedGrp in oData.aoInSchedGroups:
+            self._oDb.callProc('TestBoxLogic_addGroupEntry',
+                               ( uidAuthor, idTestBox, oInSchedGrp.idSchedGroup, oInSchedGrp.iSchedPriority,) );
+
         self._oDb.maybeCommit(fCommit);
-        return (aoRow[0], aoRow[1], aoRow[2]);
+        return (idTestBox, idGenTestBox, tsEffective);
 
 
@@ -605,30 +829,73 @@
         """
 
-        dDataErrors = oData.validateAndConvert(self._oDb, oData.ksValidateFor_Edit);
-        if len(dDataErrors) > 0:
-            raise TMInvalidData('Invalid data passed to create(): %s' % (dDataErrors,));
-
-        self._oDb.callProc('TestBoxLogic_editEntry'
-                           , ( uidAuthor,
-                               oData.idTestBox,
-                               oData.ip,            # Should we allow setting the IP?
-                               oData.uuidSystem,
-                               oData.sName,
-                               oData.sDescription,
-                               oData.idSchedGroup,
-                               oData.fEnabled,
-                               oData.enmLomKind,
-                               oData.ipLom,
-                               oData.pctScaleTimeout,
-                               oData.sComment,
-                               oData.enmPendingCmd, ));
-        aoRow = self._oDb.fetchOne();
+        #
+        # Validate.
+        #
+        self._validateAndConvertData(oData, oData.ksValidateFor_Edit);
+
+        #
+        # Get current data.
+        #
+        oOldData = TestBoxDataEx().initFromDbWithId(self._oDb, oData.idTestBox);
+
+        #
+        # Do it.
+        #
+        if not oData.isEqualEx(oOldData, [ 'tsEffective', 'tsExpire', 'uidAuthor', 'aoInSchedGroups', ]
+                                         + TestBoxData.kasMachineSettableOnly ):
+            self._oDb.callProc('TestBoxLogic_editEntry'
+                               , ( uidAuthor,
+                                   oData.idTestBox,
+                                   oData.ip,            # Should we allow setting the IP?
+                                   oData.uuidSystem,
+                                   oData.sName,
+                                   oData.sDescription,
+                                   oData.fEnabled,
+                                   oData.enmLomKind,
+                                   oData.ipLom,
+                                   oData.pctScaleTimeout,
+                                   oData.sComment,
+                                   oData.enmPendingCmd, ));
+            (idGenTestBox, tsEffective) = self._oDb.fetchOne();
+        else:
+            idGenTestBox = oOldData.idGenTestBox;
+            tsEffective  = oOldData.tsEffective;
+
+        # Calc in-group changes.
+        aoRemoved = list(oOldData.aoInSchedGroups);
+        aoNew     = [];
+        aoUpdated = [];
+        for oNewInGroup in oData.aoInSchedGroups:
+            oOldInGroup = None;
+            for iCur, oCur in enumerate(aoRemoved):
+                if oCur.idSchedGroup == oNewInGroup.idSchedGroup:
+                    oOldInGroup = aoRemoved.pop(iCur);
+                    break;
+            if oOldInGroup is None:
+                aoNew.append(oNewInGroup);
+            elif oNewInGroup.iSchedPriority != oOldInGroup.iSchedPriority:
+                aoUpdated.append(oNewInGroup);
+
+        # Remove in-groups.
+        for oInGroup in aoRemoved:
+            self._oDb.callProc('TestBoxLogic_removeGroupEntry', (uidAuthor, oData.idTestBox, oInGroup.idSchedGroup, ));
+
+        # Add new ones.
+        for oInGroup in aoNew:
+            self._oDb.callProc('TestBoxLogic_addGroupEntry',
+                               ( uidAuthor, oData.idTestBox, oInGroup.idSchedGroup, oInGroup.iSchedPriority, ) );
+
+        # Edit existing ones.
+        for oInGroup in aoUpdated:
+            self._oDb.callProc('TestBoxLogic_editGroupEntry',
+                               ( uidAuthor, oData.idTestBox, oInGroup.idSchedGroup, oInGroup.iSchedPriority, ) );
+
         self._oDb.maybeCommit(fCommit);
-        return (aoRow[0], aoRow[1]);
+        return (idGenTestBox, tsEffective);
 
 
     def removeEntry(self, uidAuthor, idTestBox, fCascade = False, fCommit = False):
         """
-        Delete user account
+        Delete test box and scheduling group associations.
         """
         self._oDb.callProc('TestBoxLogic_removeEntry'
@@ -698,9 +965,10 @@
 
     def cachedLookup(self, idTestBox):
+        # type: (int) -> TestBoxDataEx
         """
         Looks up the most recent TestBoxData object for idTestBox via
         an object cache.
 
-        Returns a shared TestBoxData object.  None if not found.
+        Returns a shared TestBoxDataEx object.  None if not found.
         Raises exception on DB error.
         """
@@ -709,4 +977,5 @@
         oEntry = self.dCache.get(idTestBox, None);
         if oEntry is None:
+            fNeedNow = False;
             self._oDb.execute('SELECT   TestBoxesWithStrings.*\n'
                               'FROM     TestBoxesWithStrings\n'
@@ -722,4 +991,5 @@
                                   'LIMIT 1\n'
                                   , (idTestBox, ));
+                fNeedNow = True;
             elif self._oDb.getRowCount() > 1:
                 raise self._oDb.integrityException('%s infinity rows for %s' % (self._oDb.getRowCount(), idTestBox));
@@ -727,5 +997,9 @@
             if self._oDb.getRowCount() == 1:
                 aaoRow = self._oDb.fetchOne();
-                oEntry = TestBoxData().initFromDbRow(aaoRow);
+                if not fNeedNow:
+                    oEntry = TestBoxDataEx().initFromDbRowEx(aaoRow, self._oDb);
+                else:
+                    oEntry = TestBoxDataEx().initFromDbRow(aaoRow);
+                    oEntry.initFromDbRowEx(aaoRow, self._oDb, tsNow = db.dbTimestampMinusOneTick(oEntry.tsExpire));
                 self.dCache[idTestBox] = oEntry;
         return oEntry;
Index: /trunk/src/VBox/ValidationKit/testmanager/core/testboxcontroller.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/testboxcontroller.py	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/testboxcontroller.py	(revision 61502)
@@ -571,5 +571,5 @@
           and oTestBoxData.fEnabled \
           and oStatusData.enmState == TestBoxStatusData.ksTestBoxState_Idle: # (paranoia)
-            dResponse = SchedulerBase.scheduleNewTask(oDb, oTestBoxData, self._oSrvGlue.getBaseUrl());
+            dResponse = SchedulerBase.scheduleNewTask(oDb, oTestBoxData, oStatusData.iWorkItem, self._oSrvGlue.getBaseUrl());
             if dResponse is not None:
                 return self._writeResponse(dResponse);
Index: /trunk/src/VBox/ValidationKit/testmanager/core/testboxstatus.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/core/testboxstatus.py	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/core/testboxstatus.py	(revision 61502)
@@ -62,4 +62,5 @@
     ksParam_enmState            = 'TestBoxStatus_enmState';
     ksParam_idTestSet           = 'TestBoxStatus_idTestSet';
+    ksParam_iWorkItem           = 'TestBoxStatus_iWorkItem';
 
     kasAllowNullAttributes      = ['idTestSet', ];
@@ -71,4 +72,6 @@
         ksTestBoxState_DoingSpecialCmd,
     ];
+
+    kcDbColumns                 = 6;
 
     def __init__(self):
@@ -84,4 +87,5 @@
         self.enmState            = self.ksTestBoxState_Idle;
         self.idTestSet           = None;
+        self.iWorkItem           = None;
 
     def initFromDbRow(self, aoRow):
@@ -99,4 +103,5 @@
         self.enmState            = aoRow[3];
         self.idTestSet           = aoRow[4];
+        self.iWorkItem           = aoRow[5];
         return self;
 
@@ -161,28 +166,12 @@
         """
         self._oDb.execute('SELECT   TestBoxStatuses.*,\n'
-                          '         TestBoxes.*,\n'
-                          '         Str1.sValue,\n'
-                          '         Str2.sValue,\n'
-                          '         Str3.sValue,\n'
-                          '         Str4.sValue,\n'
-                          '         Str5.sValue,\n'
-                          '         Str6.sValue,\n'
-                          '         Str7.sValue,\n'
-                          '         Str8.sValue\n'
+                          '         TestBoxesWithStrings.*,\n'
                           'FROM     TestBoxStatuses,\n'
-                          '         TestBoxes\n'
-                          '         LEFT OUTER JOIN TestBoxStrTab Str1 ON idStrDescription = Str1.idStr\n'
-                          '         LEFT OUTER JOIN TestBoxStrTab Str2 ON idStrComment     = Str2.idStr\n'
-                          '         LEFT OUTER JOIN TestBoxStrTab Str3 ON idStrOs          = Str3.idStr\n'
-                          '         LEFT OUTER JOIN TestBoxStrTab Str4 ON idStrOsVersion   = Str4.idStr\n'
-                          '         LEFT OUTER JOIN TestBoxStrTab Str5 ON idStrCpuVendor   = Str5.idStr\n'
-                          '         LEFT OUTER JOIN TestBoxStrTab Str6 ON idStrCpuArch     = Str6.idStr\n'
-                          '         LEFT OUTER JOIN TestBoxStrTab Str7 ON idStrCpuName     = Str7.idStr\n'
-                          '         LEFT OUTER JOIN TestBoxStrTab Str8 ON idStrReport      = Str8.idStr\n'
-                          'WHERE    TestBoxStatuses.idTestBox = %s\n'
-                          '     AND TestBoxes.idTestBox  = %s\n'
-                          '     AND TestBoxes.tsExpire   = \'infinity\'::TIMESTAMP\n'
-                          '     AND TestBoxes.uuidSystem = %s\n'
-                          '     AND TestBoxes.ip         = %s\n'
+                          '         TestBoxesWithStrings\n'
+                          'WHERE    TestBoxStatuses.idTestBox       = %s\n'
+                          '     AND TestBoxesWithStrings.idTestBox  = %s\n'
+                          '     AND TestBoxesWithStrings.tsExpire   = \'infinity\'::TIMESTAMP\n'
+                          '     AND TestBoxesWithStrings.uuidSystem = %s\n'
+                          '     AND TestBoxesWithStrings.ip         = %s\n'
                           , (idTestBox,
                              idTestBox,
@@ -196,5 +185,6 @@
             return (None, None);
         aoRow = self._oDb.fetchOne();
-        return (TestBoxStatusData().initFromDbRow(aoRow[0:5]), TestBoxData().initFromDbRow(aoRow[5:]));
+        return (TestBoxStatusData().initFromDbRow(aoRow[:TestBoxStatusData.kcDbColumns]),
+                TestBoxData().initFromDbRow(aoRow[TestBoxStatusData.kcDbColumns:]));
 
 
@@ -207,9 +197,11 @@
                           '         idGenTestBox,\n'
                           '         enmState,\n'
-                          '         idTestSet)\n'
+                          '         idTestSet,\n'
+                          '         iWorkItem)\n'
                           'VALUES ( %s,\n'
                           '         %s,\n'
                           '         \'idle\'::TestBoxState_T,\n'
-                          '         NULL)\n',
+                          '         NULL,\n',
+                          '         0)\n',
                           (idTestBox, idGenTestBox) );
         self._oDb.maybeCommit(fCommit);
@@ -251,4 +243,15 @@
                           '                       WHERE  idTestSetGangLeader = %s)\n'
                           , (sNewState, idTestSetGangLeader,) );
+        self._oDb.maybeCommit(fCommit);
+        return True;
+
+    def updateWorkItem(self, idTestBox, iWorkItem, fCommit = False):
+        """
+        Updates the testbox state.
+        """
+        self._oDb.execute('UPDATE   TestBoxStatuses\n'
+                          'SET      iWorkItem = %s\n'
+                          'WHERE    idTestBox = %s\n'
+                          , ( iWorkItem, idTestBox,));
         self._oDb.maybeCommit(fCommit);
         return True;
Index: /trunk/src/VBox/ValidationKit/testmanager/webui/wuiadmin.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/webui/wuiadmin.py	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/webui/wuiadmin.py	(revision 61502)
@@ -526,31 +526,31 @@
     def _actionTestBoxAdd(self):
         """ Action wrapper. """
-        from testmanager.core.testbox                  import TestBoxData;
+        from testmanager.core.testbox                  import TestBoxDataEx;
         from testmanager.webui.wuiadmintestbox         import WuiTestBox;
-        return self._actionGenericFormAdd(TestBoxData, WuiTestBox);
+        return self._actionGenericFormAdd(TestBoxDataEx, WuiTestBox);
 
     def _actionTestBoxAddPost(self):
         """ Action wrapper. """
-        from testmanager.core.testbox                  import TestBoxData, TestBoxLogic;
+        from testmanager.core.testbox                  import TestBoxDataEx, TestBoxLogic;
         from testmanager.webui.wuiadmintestbox         import WuiTestBox;
-        return self._actionGenericFormAddPost(TestBoxData, TestBoxLogic, WuiTestBox, self.ksActionTestBoxList);
+        return self._actionGenericFormAddPost(TestBoxDataEx, TestBoxLogic, WuiTestBox, self.ksActionTestBoxList);
 
     def _actionTestBoxDetails(self):
         """ Action wrapper. """
-        from testmanager.core.testbox                  import TestBoxData, TestBoxLogic;
+        from testmanager.core.testbox                  import TestBoxDataEx, TestBoxLogic;
         from testmanager.webui.wuiadmintestbox         import WuiTestBox;
-        return self._actionGenericFormDetails(TestBoxData, TestBoxLogic, WuiTestBox, 'idTestBox', 'idGenTestBox');
+        return self._actionGenericFormDetails(TestBoxDataEx, TestBoxLogic, WuiTestBox, 'idTestBox', 'idGenTestBox');
 
     def _actionTestBoxEdit(self):
         """ Action wrapper. """
-        from testmanager.core.testbox                  import TestBoxData;
+        from testmanager.core.testbox                  import TestBoxDataEx;
         from testmanager.webui.wuiadmintestbox         import WuiTestBox;
-        return self._actionGenericFormEdit(TestBoxData, WuiTestBox, TestBoxData.ksParam_idTestBox);
+        return self._actionGenericFormEdit(TestBoxDataEx, WuiTestBox, TestBoxDataEx.ksParam_idTestBox);
 
     def _actionTestBoxEditPost(self):
         """ Action wrapper. """
-        from testmanager.core.testbox                  import TestBoxData, TestBoxLogic;
+        from testmanager.core.testbox                  import TestBoxDataEx, TestBoxLogic;
         from testmanager.webui.wuiadmintestbox         import WuiTestBox;
-        return self._actionGenericFormEditPost(TestBoxData, TestBoxLogic,WuiTestBox, self.ksActionTestBoxList);
+        return self._actionGenericFormEditPost(TestBoxDataEx, TestBoxLogic,WuiTestBox, self.ksActionTestBoxList);
 
     def _actionTestBoxRemovePost(self):
Index: /trunk/src/VBox/ValidationKit/testmanager/webui/wuiadmintestbox.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/webui/wuiadmintestbox.py	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/webui/wuiadmintestbox.py	(revision 61502)
@@ -39,5 +39,5 @@
 from testmanager.core.db                import TMDatabaseConnection;
 from testmanager.core.schedgroup        import SchedGroupLogic, SchedGroupData;
-from testmanager.core.testbox           import TestBoxData;
+from testmanager.core.testbox           import TestBoxData, TestBoxDataEx;
 from testmanager.core.testset           import TestSetData;
 from testmanager.core.db                import isDbTimestampInfinity;
@@ -102,6 +102,4 @@
         oForm.addText(       TestBoxData.ksParam_sName,             oData.sName, 'TestBox Name');
         oForm.addText(       TestBoxData.ksParam_sDescription,      oData.sDescription, 'TestBox Description');
-        oForm.addComboBox(   TestBoxData.ksParam_idSchedGroup,      oData.idSchedGroup, 'Scheduling Group',
-                             SchedGroupLogic(TMDatabaseConnection()).getSchedGroupsForCombo());
         oForm.addCheckBox(   TestBoxData.ksParam_fEnabled,          oData.fEnabled, 'Enabled');
         oForm.addComboBox(   TestBoxData.ksParam_enmLomKind,        oData.enmLomKind, 'Lights-out-management',
@@ -110,7 +108,26 @@
         oForm.addInt(        TestBoxData.ksParam_pctScaleTimeout,   oData.pctScaleTimeout, 'Timeout scale factor (%)');
 
-        ## @todo Pretty format the read-only fields and use hidden fields for
-        #        passing the actual values. (Yes, we need the values so we can
-        #        display the form correctly on input error.)
+        oForm.addListOfSchedGroupsForTestBox(TestBoxDataEx.ksParam_aoInSchedGroups,
+                                             oData.aoInSchedGroups,
+                                             SchedGroupLogic(TMDatabaseConnection()).fetchOrderedByName(),
+                                             'Scheduling Group');
+        # Command, comment and submit button.
+        if self._sMode == WuiFormContentBase.ksMode_Edit:
+            oForm.addComboBox(TestBoxData.ksParam_enmPendingCmd,    oData.enmPendingCmd, 'Pending command',
+                              TestBoxData.kaoTestBoxCmdDescs);
+        else:
+            oForm.addComboBoxRO(TestBoxData.ksParam_enmPendingCmd,  oData.enmPendingCmd, 'Pending command',
+                                TestBoxData.kaoTestBoxCmdDescs);
+        oForm.addMultilineText(TestBoxData.ksParam_sComment,        oData.sComment, 'Comment');
+        if self._sMode != WuiFormContentBase.ksMode_Show:
+            oForm.addSubmit('Create TestBox' if self._sMode == WuiFormContentBase.ksMode_Add else 'Change TestBox');
+
+        return True;
+
+
+    def _generatePostFormContent(self, oData):
+        from testmanager.webui.wuihlpform import WuiHlpForm;
+
+        oForm = WuiHlpForm('testbox-machine-settable', '', fReadOnly = True);
         oForm.addTextRO(     TestBoxData.ksParam_sOs,               oData.sOs, 'TestBox OS');
         oForm.addTextRO(     TestBoxData.ksParam_sOsVersion,        oData.sOsVersion, 'TestBox OS version');
@@ -138,18 +155,6 @@
         oForm.addIntRO(      TestBoxData.ksParam_iPythonHexVersion, oData.iPythonHexVersion,
                              'Python version (hex)', sPostHtml = webutils.escapeElem(sHexVer));
-
-        if self._sMode == WuiFormContentBase.ksMode_Edit:
-            oForm.addComboBox(TestBoxData.ksParam_enmPendingCmd,    oData.enmPendingCmd, 'Pending command',
-                              TestBoxData.kaoTestBoxCmdDescs);
-        else:
-            oForm.addComboBoxRO(TestBoxData.ksParam_enmPendingCmd,  oData.enmPendingCmd, 'Pending command',
-                                TestBoxData.kaoTestBoxCmdDescs);
-
-        oForm.addMultilineText(TestBoxData.ksParam_sComment,        oData.sComment, 'Comment');
-
-        if self._sMode != WuiFormContentBase.ksMode_Show:
-            oForm.addSubmit('Create TestBox' if self._sMode == WuiFormContentBase.ksMode_Add else 'Change TestBox');
-
-        return True;
+        return [('Machine Only Settables', oForm.finalize()),];
+
 
 
@@ -173,4 +178,5 @@
 
     def __init__(self, aoEntries, iPage, cItemsPerPage, tsEffective, fnDPrint, oDisp):
+        # type: (list[TestBoxDataForListing], int, int, datetime.datetime, ignore, WuiAdmin) -> None
         WuiListContentWithActionBase.__init__(self, aoEntries, iPage, cItemsPerPage, tsEffective,
                                               sTitle = 'TestBoxes', sId = 'users', fnDPrint = fnDPrint, oDisp = oDisp);
@@ -237,12 +243,13 @@
         oComment = self._formatCommentCell(oEntry.sComment);
 
-        # Group link.
-        oGroup = self._dSchedGroups.get(oEntry.idSchedGroup);
-        oGroupLink = WuiTmLink(oGroup.sName if oGroup is not None else str(oEntry.idSchedGroup),
-                               WuiAdmin.ksScriptName,
-                               { WuiAdmin.ksParamAction: WuiAdmin.ksActionSchedGroupEdit,
-                                 SchedGroupData.ksParam_idSchedGroup: oEntry.idSchedGroup, },
-                               sTitle = '#%u' % (oEntry.idSchedGroup,),
-                               fBracketed = False);
+        # Group links.
+        aoGroups = [];
+        for oInGroup in oEntry.aoInSchedGroups:
+            oSchedGroup = oInGroup.oSchedGroup;
+            aoGroups.append(WuiTmLink(oSchedGroup.sName, WuiAdmin.ksScriptName,
+                                      { WuiAdmin.ksParamAction: WuiAdmin.ksActionSchedGroupEdit,
+                                        SchedGroupData.ksParam_idSchedGroup: oSchedGroup.idSchedGroup, },
+                                      sTitle = '#%u' % (oSchedGroup.idSchedGroup,),
+                                      fBracketed = len(oEntry.aoInSchedGroups) > 1));
 
         # Reformat the OS version to take less space.
@@ -338,5 +345,5 @@
                  WuiSvnLink(oEntry.iTestBoxScriptRev),
                  oEntry.formatPythonVersion(),
-                 oGroupLink,
+                 aoGroups,
                  aoOs,
                  oCpu,
Index: /trunk/src/VBox/ValidationKit/testmanager/webui/wuibase.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/webui/wuibase.py	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/webui/wuibase.py	(revision 61502)
@@ -316,5 +316,5 @@
     #
 
-    def getStringParam(self, sName, asValidValues = None, sDefault = None):
+    def getStringParam(self, sName, asValidValues = None, sDefault = None, fAllowNull = False):
         """
         Gets a string parameter.
@@ -328,5 +328,5 @@
                 raise WuiException('%s parameter "%s" is given multiple times: "%s"' % (self._sAction, sName, sValue));
             sValue = sValue.strip();
-        elif sDefault is None:
+        elif sDefault is None and fAllowNull is not True:
             raise WuiException('%s is missing parameters: "%s"' % (self._sAction, sName,));
         else:
Index: /trunk/src/VBox/ValidationKit/testmanager/webui/wuihlpform.py
===================================================================
--- /trunk/src/VBox/ValidationKit/testmanager/webui/wuihlpform.py	(revision 61501)
+++ /trunk/src/VBox/ValidationKit/testmanager/webui/wuihlpform.py	(revision 61502)
@@ -763,6 +763,6 @@
         For WuiAdminSchedGroup.
         """
-        if self._fReadOnly:
-            fReadOnly = True;
+        if fReadOnly is None or self._fReadOnly:
+            fReadOnly = self._fReadOnly;
         assert len(aoSchedGroupMembers) <= len(aoAllTestGroups);
         self._addLabel(sName, sLabel);
@@ -857,4 +857,91 @@
                          u'</table>\n');
 
+    def addListOfSchedGroupsForTestBox(self, sName, aoInSchedGroups, aoAllSchedGroups, sLabel,  # pylint: disable=R0914
+                                       fReadOnly = None):
+        # type: (str, TestBoxInSchedGroupDataEx, SchedGroupData, str, bool) -> str
+        """
+        For WuiTestGroup.
+        """
+        from testmanager.core.testbox import TestBoxInSchedGroupData, TestBoxDataEx;
+
+        if fReadOnly is None or self._fReadOnly:
+            fReadOnly = self._fReadOnly;
+        assert len(aoInSchedGroups) <= len(aoAllSchedGroups);
+
+        # Only show selected groups in read-only mode.
+        if fReadOnly:
+            aoAllSchedGroups = [oCur.oSchedGroup for oCur in aoInSchedGroups]
+
+        self._addLabel(sName, sLabel);
+        if len(aoAllSchedGroups) == 0:
+            return self._add('<li>No scheduling groups available.</li>\n')
+
+        # Add special parameter with all the scheduling group IDs in the form.
+        self._add(u'<input name="%s" type="hidden" value="%s">\n'
+                  % ( TestBoxDataEx.ksParam_aidSchedGroups,
+                      ','.join([unicode(oSchedGroup.idSchedGroup) for oSchedGroup in aoAllSchedGroups]), ));
+
+        # Table header.
+        self._add(u'<table class="tmformtbl">\n'
+                  u' <thead>\n'
+                  u'  <tr>\n'
+                  u'    <th rowspan="2"></th>\n'
+                  u'    <th rowspan="2">Schedulding Grooup</th>\n'
+                  u'    <th rowspan="2">Priority [0..31]</th>\n'
+                  u'  </tr>\n'
+                  u' </thead>\n'
+                  u' <tbody>\n'
+                  );
+
+        # Table body.
+        if self._fReadOnly:
+            fReadOnly = True;
+        sCheckBoxAttr = ' readonly onclick="return false" onkeydown="return false"' if fReadOnly else '';
+
+        oDefMember = TestBoxInSchedGroupData();
+        aoInSchedGroups = list(aoInSchedGroups); # Copy it so we can pop.
+        for iSchedGroup, oSchedGroup in enumerate(aoAllSchedGroups):
+
+            # Is it a member?
+            oMember = None;
+            for i, _ in enumerate(aoInSchedGroups):
+                if aoInSchedGroups[i].idSchedGroup == oSchedGroup.idSchedGroup:
+                    oMember = aoInSchedGroups.pop(i);
+                    break;
+
+            # Start on the rows...
+            sPrefix = u'%s[%d]' % (sName, oSchedGroup.idSchedGroup,);
+            self._add(u'  <tr class="%s">\n'
+                      u'    <td>\n'
+                      u'      <input name="%s[%s]" type="hidden" value="%s">\n' # idSchedGroup
+                      u'      <input name="%s[%s]" type="hidden" value="%s">\n' # idTestBox
+                      u'      <input name="%s[%s]" type="hidden" value="%s">\n' # tsExpire
+                      u'      <input name="%s[%s]" type="hidden" value="%s">\n' # tsEffective
+                      u'      <input name="%s[%s]" type="hidden" value="%s">\n' # uidAuthor
+                      u'      <input name="%s" type="checkbox"%s%s value="%d" class="tmform-checkbox" title="#%d - %s">\n' #(list)
+                      u'    </td>\n'
+                      % ( 'tmodd' if iSchedGroup & 1 else 'tmeven',
+                          sPrefix, TestBoxInSchedGroupData.ksParam_idSchedGroup,  oSchedGroup.idSchedGroup,
+                          sPrefix, TestBoxInSchedGroupData.ksParam_idTestBox,   -1 if oMember is None else oMember.idTestBox,
+                          sPrefix, TestBoxInSchedGroupData.ksParam_tsExpire,    '' if oMember is None else oMember.tsExpire,
+                          sPrefix, TestBoxInSchedGroupData.ksParam_tsEffective, '' if oMember is None else oMember.tsEffective,
+                          sPrefix, TestBoxInSchedGroupData.ksParam_uidAuthor,   '' if oMember is None else oMember.uidAuthor,
+                          TestBoxDataEx.ksParam_aoInSchedGroups, '' if oMember is None else ' checked', sCheckBoxAttr,
+                          oSchedGroup.idSchedGroup, oSchedGroup.idSchedGroup, escapeElem(oSchedGroup.sName),
+                          ));
+            self._add(u'    <td align="left">%s</td>\n' % ( escapeElem(oSchedGroup.sName), ));
+
+            self._add(u'    <td align="center">\n'
+                      u'      <input name="%s[%s]" type="text" value="%s" style="max-width:3em;" %s>\n'
+                      u'    </td>\n'
+                      % ( sPrefix, TestBoxInSchedGroupData.ksParam_iSchedPriority,
+                          (oMember if oMember is not None else oDefMember).iSchedPriority,
+                          ' readonly class="tmform-input-readonly"' if fReadOnly else '', ));
+            self._add(u'  </tr>\n');
+
+        return self._add(u' </tbody>\n'
+                         u'</table>\n');
+
+
     #
     # Buttons.
