Repository: pypi/warehouse

Issue:
Ability to add new classifiers.
On the admin side of things, we need a simple way to add new classifiers.


Discussion:
In our [meeting today](https://wiki.python.org/psf/PackagingWG/2018-02-12-Warehouse) Ernest said that this is something he would like in the admin UI before launch.
@lgh2 is about to start working on this.
In IRC conversation we found out Dustin had already started working on this, so now it's on his plate. :)

Resolving patch:
diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py
--- a/warehouse/admin/routes.py
+++ b/warehouse/admin/routes.py
@@ -91,6 +91,18 @@ def includeme(config):
         domain=warehouse,
     )
 
+    # Classifier related Admin pages
+    config.add_route(
+        'admin.classifiers',
+        '/admin/classifiers/',
+        domain=warehouse,
+    )
+    config.add_route(
+        'admin.classifiers.add',
+        '/admin/classifiers/add/',
+        domain=warehouse,
+    )
+
     # Blacklist related Admin pages
     config.add_route(
         "admin.blacklist.list",
diff --git a/warehouse/admin/views/classifiers.py b/warehouse/admin/views/classifiers.py
new file mode 100644
--- /dev/null
+++ b/warehouse/admin/views/classifiers.py
@@ -0,0 +1,69 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from pyramid.httpexceptions import HTTPSeeOther
+from pyramid.view import view_config
+
+from warehouse.packaging.models import Classifier
+
+
+@view_config(
+    route_name="admin.classifiers",
+    renderer="admin/classifiers/index.html",
+    permission="admin",
+    uses_session=True,
+)
+def get_classifiers(request):
+    classifiers = (
+        request.db.query(Classifier)
+        .filter(Classifier.l5 == 0)  # Can be a parent
+        .order_by(Classifier.classifier)
+        .all()
+    )
+
+    return {
+        'classifiers': classifiers,
+    }
+
+
+@view_config(
+    route_name="admin.classifiers.add",
+    permission="admin",
+    request_method="POST",
+    uses_session=True,
+    require_methods=False,
+    require_csrf=True,
+)
+def add_classifier(request):
+    parent = request.db.query(Classifier).get(request.params.get('parent_id'))
+
+    classifier = Classifier(
+        l2=parent.l2,
+        l3=parent.l3,
+        l4=parent.l4,
+        l5=parent.l5,
+        classifier=parent.classifier + " :: " + request.params.get('child'),
+    )
+    request.db.add(classifier)
+    request.db.flush()  # To get the ID
+
+    for level in ['l3', 'l4', 'l5']:
+        if getattr(classifier, level) == 0:
+            setattr(classifier, level, classifier.id)
+            break
+
+    request.session.flash(
+        f"Successfully added classifier {classifier.classifier!r}",
+        queue="success",
+    )
+
+    return HTTPSeeOther(request.route_path("admin.classifiers"))
diff --git a/warehouse/migrations/versions/2730e54f8717_reset_classifier_id_sequence.py b/warehouse/migrations/versions/2730e54f8717_reset_classifier_id_sequence.py
new file mode 100644
--- /dev/null
+++ b/warehouse/migrations/versions/2730e54f8717_reset_classifier_id_sequence.py
@@ -0,0 +1,35 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+Reset Classifier ID sequence
+
+Revision ID: 2730e54f8717
+Revises: 8fd3400c760f
+Create Date: 2018-03-14 16:34:38.151300
+"""
+
+from alembic import op
+
+
+revision = '2730e54f8717'
+down_revision = '8fd3400c760f'
+
+
+def upgrade():
+    op.execute("""
+        SELECT setval('trove_classifiers_id_seq', max(id))
+        FROM trove_classifiers;
+    """)
+
+
+def downgrade():
+    pass


Test patch:
diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py
--- a/tests/unit/admin/test_routes.py
+++ b/tests/unit/admin/test_routes.py
@@ -91,6 +91,16 @@ def test_includeme():
             "/admin/journals/",
             domain=warehouse,
         ),
+        pretend.call(
+            'admin.classifiers',
+            '/admin/classifiers/',
+            domain=warehouse,
+        ),
+        pretend.call(
+            'admin.classifiers.add',
+            '/admin/classifiers/add/',
+            domain=warehouse,
+        ),
         pretend.call(
             "admin.blacklist.list",
             "/admin/blacklist/",
diff --git a/tests/unit/admin/views/test_classifiers.py b/tests/unit/admin/views/test_classifiers.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/admin/views/test_classifiers.py
@@ -0,0 +1,72 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pretend
+import pytest
+
+from warehouse.admin.views import classifiers as views
+from warehouse.classifiers.models import Classifier
+
+from ....common.db.classifiers import ClassifierFactory
+
+
+class TestGetClassifiers:
+
+    def test_get_classifiers(self, db_request):
+        classifier_a = ClassifierFactory(l5=0, classifier='I am first')
+        classifier_b = ClassifierFactory(l5=0, classifier='I am last')
+        ClassifierFactory(l5=1)  # Ignored because it has a nonzero L5
+
+        assert views.get_classifiers(db_request) == {
+            'classifiers': [classifier_a, classifier_b],
+        }
+
+
+class TestAddClassifier:
+
+    @pytest.mark.parametrize(
+        'parent_levels, expected_levels',
+        [
+            ((2, 0, 0, 0), (2, None, 0, 0)),
+            ((2, 3, 0, 0), (2, 3, None, 0)),
+            ((2, 3, 4, 0), (2, 3, 4, None)),
+
+            # This won't actually happen but it's needed for coverage
+            ((2, 3, 4, 5), (2, 3, 4, 5)),
+        ]
+    )
+    def test_add_classifier(self, db_request, parent_levels, expected_levels):
+        l2, l3, l4, l5 = parent_levels
+        parent = ClassifierFactory(
+            l2=l2, l3=l3, l4=l4, l5=l5, classifier='Parent'
+        )
+
+        db_request.params = {
+            'parent_id': parent.id,
+            'child': 'Foobar',
+        }
+        db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None)
+        db_request.route_path = lambda *a: '/the/path'
+
+        views.add_classifier(db_request)
+
+        new = (
+            db_request.db.query(Classifier)
+            .filter(Classifier.classifier == 'Parent :: Foobar')
+            .one()
+        )
+
+        new_l2, new_l3, new_l4, new_l5 = expected_levels
+        assert new.l2 == new_l2 if new_l2 is not None else new.id
+        assert new.l3 == new_l3 if new_l3 is not None else new.id
+        assert new.l4 == new_l4 if new_l4 is not None else new.id
+        assert new.l5 == new_l5 if new_l5 is not None else new.id