Add a command to delete a test suite from a server (#162)

This will make administration of LNT instances much simpler.
diff --git a/docs/api.rst b/docs/api.rst
index 4bb49ec..e86cedd 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -22,6 +22,8 @@
 +-------+-------------------------------------------------------+---------------------------+
 | POST  | /schema                                               | **Yes**                   |
 +-------+-------------------------------------------------------+---------------------------+
+| DELETE| /schema                                               | **Yes**                   |
++-------+-------------------------------------------------------+---------------------------+
 | GET   | /machines                                             | No                        |
 +-------+-------------------------------------------------------+---------------------------+
 | GET   | /machines/<machine_spec>                              | No                        |
@@ -243,6 +245,37 @@
 * 401 Unauthorized - Missing or invalid AuthToken
 * 415 Unsupported Media Type - Content-Type is not application/x-yaml
 
+**DELETE** ``/api/db_<database>/v4/<testsuite>/schema``
+
+Deletes the test suite schema and drops all associated tables. Requires authentication.
+
+**Headers:**
+
+* ``AuthToken: <token>`` (required)
+
+**Example:**
+
+.. code-block:: bash
+
+    curl --request DELETE \
+         --header "AuthToken: SomeSecret" \
+         http://localhost:8000/api/db_default/v4/my_suite/schema
+
+**Response (200 OK):**
+
+.. code-block:: json
+
+    {
+        "generated_by": "LNT Server <version>",
+        "testsuite": "my_suite"
+    }
+
+**Error Responses:**
+
+* 401 Unauthorized - Missing or invalid AuthToken
+* 404 Not Found - Unknown test suite
+* 500 Internal Server Error - Failed to delete test suite or drop tables
+
 Machines
 ~~~~~~~~
 
diff --git a/docs/tools.rst b/docs/tools.rst
index f88695f..4b5a8dc 100644
--- a/docs/tools.rst
+++ b/docs/tools.rst
@@ -93,6 +93,9 @@
   ``lnt admin test-suite add <schema.yaml>``
   Add a new test suite to the server with the specified YAML schema.
 
+  ``lnt admin test-suite delete <testsuite>``
+  Delete the specified test suite.
+
   ``lnt admin rm-run <run>+``
   Remove the specified runs and related samples.
 
diff --git a/lnt/lnttool/admin.py b/lnt/lnttool/admin.py
index a943421..41272ee 100644
--- a/lnt/lnttool/admin.py
+++ b/lnt/lnttool/admin.py
@@ -360,6 +360,30 @@
             sys.stderr.write(response.text)
 
 
+@action_test_suite.command("delete")
+@_pass_config
+@click.argument("testsuite_name", required=True)
+def action_test_suite_delete(config, testsuite_name):
+    """Delete a test suite from the server."""
+    _check_auth_token(config)
+
+    url = f"{config.lnt_url}/api/db_{config.database}/v4/{testsuite_name}/schema"
+    response = config.session.delete(url)
+    _check_response(response)
+
+    try:
+        response_data = json.loads(response.text)
+        deleted_suite = response_data.get('testsuite')
+        if deleted_suite:
+            sys.stdout.write(f"{deleted_suite}\n")
+            return
+        if config.verbose:
+            json.dump(response_data, sys.stderr, indent=2, sort_keys=True)
+    except Exception:
+        if config.verbose:
+            sys.stderr.write(response.text)
+
+
 @click.command('create-config')
 def action_create_config():
     """Create example configuration."""
diff --git a/lnt/server/db/testsuite.py b/lnt/server/db/testsuite.py
index af88d41..7ee0154 100644
--- a/lnt/server/db/testsuite.py
+++ b/lnt/server/db/testsuite.py
@@ -534,3 +534,34 @@
                      testsuite.sample_fields)
         testsuite = existing_ts
     return testsuite
+
+
+def delete_testsuite(session, name):
+    suite = session.query(TestSuite) \
+        .filter(TestSuite.name == name).first()
+    if suite is None:
+        return None
+
+    suite_id = suite.id
+    session.expunge(suite)
+
+    session.query(TestSuiteJSONSchema) \
+        .filter(TestSuiteJSONSchema.testsuite_name == name) \
+        .delete(synchronize_session=False)
+    session.query(MachineField) \
+        .filter(MachineField.test_suite_id == suite_id) \
+        .delete(synchronize_session=False)
+    session.query(OrderField) \
+        .filter(OrderField.test_suite_id == suite_id) \
+        .delete(synchronize_session=False)
+    session.query(RunField) \
+        .filter(RunField.test_suite_id == suite_id) \
+        .delete(synchronize_session=False)
+    session.query(SampleField) \
+        .filter(SampleField.test_suite_id == suite_id) \
+        .delete(synchronize_session=False)
+
+    session.query(TestSuite) \
+        .filter(TestSuite.id == suite_id) \
+        .delete(synchronize_session=False)
+    return suite
diff --git a/lnt/server/db/testsuitedb.py b/lnt/server/db/testsuitedb.py
index 3dc6419..81e0d4b 100644
--- a/lnt/server/db/testsuitedb.py
+++ b/lnt/server/db/testsuitedb.py
@@ -775,6 +775,9 @@
     def create_tables(self, engine):
         self.base.metadata.create_all(engine)
 
+    def drop_tables(self, engine):
+        self.base.metadata.drop_all(engine)
+
     def get_baselines(self, session):
         return session.query(self.Baseline).all()
 
diff --git a/lnt/server/ui/api.py b/lnt/server/ui/api.py
index 00119c9..b4d3ffe 100644
--- a/lnt/server/ui/api.py
+++ b/lnt/server/ui/api.py
@@ -9,12 +9,12 @@
 from sqlalchemy.orm import joinedload
 from sqlalchemy.orm.exc import NoResultFound
 
+from lnt.server.db import testsuite
+from lnt.server.db import testsuitedb
 from lnt.server.ui.util import convert_revision
 from lnt.server.ui.decorators import in_db
 from lnt.testing import PASS
 from lnt.util import logger
-from lnt.server.db import testsuite
-from lnt.server.db import testsuitedb
 from functools import wraps
 
 
@@ -428,6 +428,35 @@
         result['schema'] = suite.__json__()
         return result, 201
 
+    @staticmethod
+    @requires_auth_token
+    def delete():
+        session = request.session
+        suite_name = g.testsuite_name
+        suite = session.query(testsuite.TestSuite) \
+            .filter(testsuite.TestSuite.name == suite_name).first()
+        if suite is None:
+            abort(404, msg=f"Unknown test suite '{suite_name}'.")
+
+        tsdb = request.db.testsuite.get(suite_name)
+        if tsdb is None:
+            tsdb = testsuitedb.TestSuiteDB(request.db, suite_name, suite)
+
+        try:
+            tsdb.drop_tables(request.db.engine)
+            testsuite.delete_testsuite(session, suite_name)
+            session.commit()
+        except Exception as exc:
+            session.rollback()
+            abort(500, msg=f"Failed to delete test suite '{suite_name}': {exc}")
+
+        request.db.testsuite.pop(suite_name, None)
+        request.db.testsuite = dict(sorted(request.db.testsuite.items()))
+
+        result = common_fields_factory()
+        result['testsuite'] = suite_name
+        return result
+
 
 class SampleData(Resource):
     method_decorators = [in_db]
diff --git a/tests/lnttool/admin/test-suite/delete.shtest b/tests/lnttool/admin/test-suite/delete.shtest
new file mode 100644
index 0000000..48ea879
--- /dev/null
+++ b/tests/lnttool/admin/test-suite/delete.shtest
@@ -0,0 +1,45 @@
+# RUN: rm -rf %t.instance
+# RUN: rm -rf %t.tmp && mkdir -p %t.tmp
+# RUN: python %{shared_inputs}/create_temp_instance.py \
+# RUN:   %s %{shared_inputs}/SmallInstance %t.instance
+# RUN: %{shared_inputs}/server_wrapper.sh %t.instance 9096 /bin/sh %s %t.tmp %{shared_inputs}
+
+set -eux
+DIR="$1"
+SHARED_INPUTS="$2"
+cd "$DIR"
+
+cat > lntadmin.yaml << '__EOF__'
+lnt_url: "http://localhost:9096"
+database: default
+testsuite: delete_test_suite
+auth_token: test_token
+__EOF__
+
+SCHEMA_URL="http://localhost:9096/api/db_default/v4/delete_test_suite/schema"
+
+cat > schema.yaml << '__EOF__'
+format_version: '2'
+name: delete_test_suite
+metrics:
+  - name: execution_time
+    type: Real
+    unit: seconds
+run_fields:
+  - name: build_revision
+    order: true
+machine_fields:
+  - name: hardware
+__EOF__
+
+lnt admin --testsuite delete_test_suite test-suite add schema.yaml > add_schema.stdout 2>&1
+# RUN: filecheck %s --check-prefix=ADD_SCHEMA < %t.tmp/add_schema.stdout
+# ADD_SCHEMA: delete_test_suite
+
+lnt admin test-suite delete delete_test_suite > delete_schema.stdout 2>&1
+# RUN: filecheck %s --check-prefix=DELETE_SCHEMA < %t.tmp/delete_schema.stdout
+# DELETE_SCHEMA: delete_test_suite
+
+curl -s -o schema_response.txt -w "%{http_code}" "$SCHEMA_URL" > schema_status.txt 2>&1
+# RUN: filecheck %s --check-prefix=SCHEMA_STATUS < %t.tmp/schema_status.txt
+# SCHEMA_STATUS: 404
diff --git a/tests/server/api/schema/delete.py b/tests/server/api/schema/delete.py
new file mode 100644
index 0000000..c0f34f3
--- /dev/null
+++ b/tests/server/api/schema/delete.py
@@ -0,0 +1,98 @@
+# This test checks the /schema DELETE API that allows removing a schema.
+
+# RUN: rm -rf %t.instance
+# RUN: python %{shared_inputs}/create_temp_instance.py \
+# RUN:     %s %{shared_inputs}/SmallInstance \
+# RUN:     %t.instance %S/../../ui/Inputs/V4Pages_extra_records.sql
+#
+# RUN: python %s %t.instance
+# END.
+
+import json
+import logging
+import sys
+import unittest
+import lnt.server.ui.app
+
+logging.basicConfig(level=logging.INFO)
+
+
+class SchemaDeleteApiTest(unittest.TestCase):
+    """Test DELETE /schema endpoint for schema removal."""
+
+    def setUp(self):
+        _, instance_path = sys.argv
+        app = lnt.server.ui.app.App.create_standalone(instance_path)
+        app.testing = True
+        self.client = app.test_client()
+
+    def _schema_payload(self, name):
+        return f"""
+format_version: '2'
+name: {name}
+metrics:
+- name: execution_time
+  type: Real
+  unit: seconds
+run_fields:
+- name: build_revision
+  order: true
+machine_fields:
+- name: hardware
+"""
+
+    def _post_schema(self, name):
+        payload = self._schema_payload(name)
+        return self.client.post(
+            f"api/db_default/v4/{name}/schema",
+            data=payload,
+            content_type="application/x-yaml",
+            headers={"AuthToken": "test_token"},
+        )
+
+    def test_delete_requires_auth(self):
+        resp = self._post_schema("schema_delete_suite")
+        self.assertEqual(resp.status_code, 201, resp.data.decode("utf-8"))
+
+        resp = self.client.delete(
+            "api/db_default/v4/schema_delete_suite/schema",
+        )
+        self.assertEqual(resp.status_code, 401)
+
+    def test_delete_schema_success(self):
+        resp = self._post_schema("schema_delete_suite")
+        self.assertEqual(resp.status_code, 201, resp.data.decode("utf-8"))
+
+        resp = self.client.delete(
+            "api/db_default/v4/schema_delete_suite/schema",
+            headers={"AuthToken": "test_token"},
+        )
+        self.assertEqual(resp.status_code, 200, resp.data.decode("utf-8"))
+        result = json.loads(resp.data)
+        self.assertEqual(result["testsuite"], "schema_delete_suite")
+
+        resp = self.client.get("api/db_default/v4/schema_delete_suite/schema")
+        self.assertEqual(resp.status_code, 404)
+
+    def test_delete_unknown_suite(self):
+        resp = self.client.delete(
+            "api/db_default/v4/does_not_exist/schema",
+            headers={"AuthToken": "test_token"},
+        )
+        self.assertEqual(resp.status_code, 404)
+
+    def test_delete_default_nts_suite(self):
+        resp = self.client.delete(
+            "api/db_default/v4/nts/schema",
+            headers={"AuthToken": "test_token"},
+        )
+        self.assertEqual(resp.status_code, 200, resp.data.decode("utf-8"))
+        result = json.loads(resp.data)
+        self.assertEqual(result["testsuite"], "nts")
+
+        resp = self.client.get("api/db_default/v4/nts/schema")
+        self.assertEqual(resp.status_code, 404)
+
+
+if __name__ == "__main__":
+    unittest.main(argv=[sys.argv[0], ])