Repository: pypi/warehouse

Issue:
Add admin ability to reassign project to another user
Legacy PyPI allowed admins to take over an existing project, but Warehouse doesn't have a mechanism to allow an admin to re-assign a project to another user.



Discussion:
@nlhkabu I'm going to work on this during the ep18 sprints.

The plan is to add an expandable section at the bottom of the project's "view" page in the admin similar to "Delete Project" or "Set Upload Limit"
Since a project might have multiple owners, it would be ideal if this feature allowed us to add/remove roles for a given project.
(Also probably worth mentioning that the users in the dev DB with admin access are `dstufft`, `ewdurbin` and `di`)

Resolving patch:
diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py
--- a/warehouse/admin/routes.py
+++ b/warehouse/admin/routes.py
@@ -66,6 +66,20 @@ def includeme(config):
         traverse="/{project_name}",
         domain=warehouse,
     )
+    config.add_route(
+        "admin.project.add_role",
+        "/admin/projects/{project_name}/add_role/",
+        factory="warehouse.packaging.models:ProjectFactory",
+        traverse="/{project_name}",
+        domain=warehouse,
+    )
+    config.add_route(
+        "admin.project.delete_role",
+        "/admin/projects/{project_name}/delete_role/{role_id}/",
+        factory="warehouse.packaging.models:ProjectFactory",
+        traverse="/{project_name}",
+        domain=warehouse,
+    )
     config.add_route(
         "admin.project.delete",
         "/admin/projects/{project_name}/delete/",
diff --git a/warehouse/admin/views/projects.py b/warehouse/admin/views/projects.py
--- a/warehouse/admin/views/projects.py
+++ b/warehouse/admin/views/projects.py
@@ -16,6 +16,7 @@
 from pyramid.httpexceptions import HTTPBadRequest, HTTPMovedPermanently, HTTPSeeOther
 from pyramid.view import view_config
 from sqlalchemy import or_
+from sqlalchemy.orm.exc import NoResultFound
 
 from warehouse.accounts.models import User
 from warehouse.packaging.models import Project, Release, Role, JournalEntry
@@ -271,6 +272,125 @@ def set_upload_limit(project, request):
     )
 
 
+@view_config(
+    route_name="admin.project.add_role",
+    permission="admin",
+    request_method="POST",
+    uses_session=True,
+    require_methods=False,
+)
+def add_role(project, request):
+    username = request.POST.get("username")
+    if not username:
+        request.session.flash("Provide a username", queue="error")
+        raise HTTPSeeOther(
+            request.route_path(
+                "admin.project.detail", project_name=project.normalized_name
+            )
+        )
+
+    try:
+        user = request.db.query(User).filter(User.username == username).one()
+    except NoResultFound:
+        request.session.flash(f"Unknown username '{username}'", queue="error")
+        raise HTTPSeeOther(
+            request.route_path(
+                "admin.project.detail", project_name=project.normalized_name
+            )
+        )
+
+    role_name = request.POST.get("role_name")
+    if not role_name:
+        request.session.flash("Provide a role", queue="error")
+        raise HTTPSeeOther(
+            request.route_path(
+                "admin.project.detail", project_name=project.normalized_name
+            )
+        )
+
+    already_there = (
+        request.db.query(Role)
+        .filter(Role.user == user, Role.project == project)
+        .count()
+    )
+
+    if already_there > 0:
+        request.session.flash(
+            f"User '{user.username}' already has a role on this project", queue="error"
+        )
+        raise HTTPSeeOther(
+            request.route_path(
+                "admin.project.detail", project_name=project.normalized_name
+            )
+        )
+
+    request.db.add(
+        JournalEntry(
+            name=project.name,
+            action=f"add {role_name} {user.username}",
+            submitted_by=request.user,
+            submitted_from=request.remote_addr,
+        )
+    )
+
+    request.db.add(Role(role_name=role_name, user=user, project=project))
+
+    request.session.flash(
+        f"Added '{user.username}' as '{role_name}' on '{project.name}'", queue="success"
+    )
+    return HTTPSeeOther(
+        request.route_path("admin.project.detail", project_name=project.normalized_name)
+    )
+
+
+@view_config(
+    route_name="admin.project.delete_role",
+    permission="admin",
+    request_method="POST",
+    uses_session=True,
+    require_methods=False,
+)
+def delete_role(project, request):
+    confirm = request.POST.get("username")
+    role_id = request.matchdict.get("role_id")
+
+    role = request.db.query(Role).get(role_id)
+    if not role:
+        request.session.flash(f"This role no longer exists", queue="error")
+        raise HTTPSeeOther(
+            request.route_path(
+                "admin.project.detail", project_name=project.normalized_name
+            )
+        )
+
+    if not confirm or confirm != role.user_name:
+        request.session.flash("Confirm the request", queue="error")
+        raise HTTPSeeOther(
+            request.route_path(
+                "admin.project.detail", project_name=project.normalized_name
+            )
+        )
+
+    request.session.flash(
+        f"Removed '{role.user_name}' as '{role.role_name}' on '{project.name}'",
+        queue="success",
+    )
+    request.db.add(
+        JournalEntry(
+            name=project.name,
+            action=f"remove {role.role_name} {role.user_name}",
+            submitted_by=request.user,
+            submitted_from=request.remote_addr,
+        )
+    )
+
+    request.db.delete(role)
+
+    return HTTPSeeOther(
+        request.route_path("admin.project.detail", project_name=project.normalized_name)
+    )
+
+
 @view_config(
     route_name="admin.project.delete",
     permission="admin",


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
@@ -69,6 +69,20 @@ def test_includeme():
             traverse="/{project_name}",
             domain=warehouse,
         ),
+        pretend.call(
+            "admin.project.add_role",
+            "/admin/projects/{project_name}/add_role/",
+            factory="warehouse.packaging.models:ProjectFactory",
+            traverse="/{project_name}",
+            domain=warehouse,
+        ),
+        pretend.call(
+            "admin.project.delete_role",
+            "/admin/projects/{project_name}/delete_role/{role_id}/",
+            factory="warehouse.packaging.models:ProjectFactory",
+            traverse="/{project_name}",
+            domain=warehouse,
+        ),
         pretend.call(
             "admin.project.delete",
             "/admin/projects/{project_name}/delete/",
diff --git a/tests/unit/admin/views/test_projects.py b/tests/unit/admin/views/test_projects.py
--- a/tests/unit/admin/views/test_projects.py
+++ b/tests/unit/admin/views/test_projects.py
@@ -12,11 +12,12 @@
 
 import pretend
 import pytest
+import uuid
 
 from pyramid.httpexceptions import HTTPBadRequest, HTTPMovedPermanently, HTTPSeeOther
 
 from warehouse.admin.views import projects as views
-from warehouse.packaging.models import Project
+from warehouse.packaging.models import Project, Role
 
 from ....common.db.accounts import UserFactory
 from ....common.db.packaging import (
@@ -431,3 +432,164 @@ def test_deletes_project(self, db_request):
         ]
 
         assert not (db_request.db.query(Project).filter(Project.name == "foo").count())
+
+
+class TestAddRole:
+    def test_add_role(self, db_request):
+        role_name = "Maintainer"
+        project = ProjectFactory.create(name="foo")
+        user = UserFactory.create(username="bar")
+
+        db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect/")
+        db_request.session = pretend.stub(
+            flash=pretend.call_recorder(lambda *a, **kw: None)
+        )
+        db_request.POST["username"] = user.username
+        db_request.POST["role_name"] = role_name
+        db_request.user = UserFactory.create()
+        db_request.remote_addr = "192.168.1.1"
+
+        views.add_role(project, db_request)
+
+        assert db_request.session.flash.calls == [
+            pretend.call(f"Added 'bar' as '{role_name}' on 'foo'", queue="success")
+        ]
+
+        role = db_request.db.query(Role).one()
+        assert role.role_name == role_name
+        assert role.user == user
+        assert role.project == project
+
+    def test_add_role_no_username(self, db_request):
+        project = ProjectFactory.create(name="foo")
+
+        db_request.POST = {}
+        db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect/")
+        db_request.session = pretend.stub(
+            flash=pretend.call_recorder(lambda *a, **kw: None)
+        )
+
+        with pytest.raises(HTTPSeeOther):
+            views.add_role(project, db_request)
+
+        assert db_request.session.flash.calls == [
+            pretend.call(f"Provide a username", queue="error")
+        ]
+
+    def test_add_role_no_user(self, db_request):
+        project = ProjectFactory.create(name="foo")
+
+        db_request.POST = {"username": "bar"}
+        db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect/")
+        db_request.session = pretend.stub(
+            flash=pretend.call_recorder(lambda *a, **kw: None)
+        )
+
+        with pytest.raises(HTTPSeeOther):
+            views.add_role(project, db_request)
+
+        assert db_request.session.flash.calls == [
+            pretend.call(f"Unknown username 'bar'", queue="error")
+        ]
+
+    def test_add_role_no_role_name(self, db_request):
+        project = ProjectFactory.create(name="foo")
+        UserFactory.create(username="bar")
+
+        db_request.POST = {"username": "bar"}
+        db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect/")
+        db_request.session = pretend.stub(
+            flash=pretend.call_recorder(lambda *a, **kw: None)
+        )
+
+        with pytest.raises(HTTPSeeOther):
+            views.add_role(project, db_request)
+
+        assert db_request.session.flash.calls == [
+            pretend.call(f"Provide a role", queue="error")
+        ]
+
+    def test_add_role_with_existing_role(self, db_request):
+        project = ProjectFactory.create(name="foo")
+        user = UserFactory.create(username="bar")
+        role = RoleFactory.create(project=project, user=user)
+
+        db_request.POST = {"username": "bar", "role_name": role.role_name}
+        db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect/")
+        db_request.session = pretend.stub(
+            flash=pretend.call_recorder(lambda *a, **kw: None)
+        )
+
+        with pytest.raises(HTTPSeeOther):
+            views.add_role(project, db_request)
+
+        assert db_request.session.flash.calls == [
+            pretend.call(
+                f"User 'bar' already has a role on this project", queue="error"
+            )
+        ]
+
+
+class TestDeleteRole:
+    def test_delete_role(self, db_request):
+        project = ProjectFactory.create(name="foo")
+        user = UserFactory.create(username="bar")
+        role = RoleFactory.create(project=project, user=user)
+
+        db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect/")
+        db_request.session = pretend.stub(
+            flash=pretend.call_recorder(lambda *a, **kw: None)
+        )
+        db_request.POST["username"] = user.username
+        db_request.matchdict["role_id"] = role.id
+        db_request.user = UserFactory.create()
+        db_request.remote_addr = "192.168.1.1"
+
+        views.delete_role(project, db_request)
+
+        assert db_request.session.flash.calls == [
+            pretend.call(
+                f"Removed '{role.user_name}' as '{role.role_name}' on '{project.name}'",
+                queue="success",
+            )
+        ]
+
+        assert db_request.db.query(Role).all() == []
+
+    def test_delete_role_not_found(self, db_request):
+        project = ProjectFactory.create(name="foo")
+
+        db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect/")
+        db_request.session = pretend.stub(
+            flash=pretend.call_recorder(lambda *a, **kw: None)
+        )
+        db_request.matchdict["role_id"] = uuid.uuid4()
+        db_request.user = UserFactory.create()
+        db_request.remote_addr = "192.168.1.1"
+
+        with pytest.raises(HTTPSeeOther):
+            views.delete_role(project, db_request)
+
+        assert db_request.session.flash.calls == [
+            pretend.call("This role no longer exists", queue="error")
+        ]
+
+    def test_delete_role_no_confirm(self, db_request):
+        project = ProjectFactory.create(name="foo")
+        user = UserFactory.create(username="bar")
+        role = RoleFactory.create(project=project, user=user)
+
+        db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect/")
+        db_request.session = pretend.stub(
+            flash=pretend.call_recorder(lambda *a, **kw: None)
+        )
+        db_request.matchdict["role_id"] = role.id
+        db_request.user = UserFactory.create()
+        db_request.remote_addr = "192.168.1.1"
+
+        with pytest.raises(HTTPSeeOther):
+            views.delete_role(project, db_request)
+
+        assert db_request.session.flash.calls == [
+            pretend.call("Confirm the request", queue="error")
+        ]