Repository: pypi/warehouse

Issue:
[Project-scoped API tokens] aren't available to maintainers
**Describe the bug**
<!-- A clear and concise description the bug -->
When I use a "bot" account with "Maintainer" level access to projects, there are no projects to select from in the form for the token creation.

**Expected behavior**
<!-- A clear and concise description of what you expected to happen -->
Since this "bot" can upload dists using user/password auth, it should also have similar privileges set when using tokens.

**To Reproduce**
<!-- Steps to reproduce the bug, or a link to PyPI where the bug is visible -->
Go to https://pypi.org/manage/account/token and try selecting a project where you have only "Maintainer"-level access, not "Owner".

**My Platform**

N/A

**Additional context**
<!-- Add any other context, links, etc. about the feature here. -->

N/A


Discussion:
Yup, I can confirm this. If I am logged in as a user who has maintainer-level collaborator status on a project, that project does not appear in the Scope dropdown list at `manage/account/token/`.

This is a bug IMO and we should fix it before further publicizing the feature (per rollout tracked in #5661).

Thanks for finding and reporting this bug @webknjaz! :trophy: 
Hmm, yeah, this is probably just a matter of how/which projects we iterate over for the user.

I'll check this out, thanks @webknjaz!
> just a matter of how/which projects we iterate over

That's my guess too.
Yeah, here's the root-cause: We use the `user_projects` helper in `manage/views.py`, which returns projects based on just ownership:

```python
def user_projects(request):
    """ Return all the projects for which the user is a sole owner """
    projects_owned = (
        request.db.query(Project.id)
        .join(Role.project)
        .filter(Role.role_name == "Owner", Role.user == request.user)
        .subquery()
    )

    with_sole_owner = (
        request.db.query(Role.project_id)
        .join(projects_owned)
        .filter(Role.role_name == "Owner")
        .group_by(Role.project_id)
        .having(func.count(Role.project_id) == 1)
        .subquery()
    )

    return {
        "projects_owned": (
            request.db.query(Project)
            .join(projects_owned, Project.id == projects_owned.c.id)
            .order_by(Project.name)
            .all()
        ),
        "projects_sole_owned": (
            request.db.query(Project).join(with_sole_owner).order_by(Project.name).all()
        ),
    }
```

We could either amend that helper to include a sub-dict for maintainer roles as well, or add a new helper just for macaroons that returns just project names for those two roles. 


Resolving patch:
diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py
--- a/warehouse/manage/views.py
+++ b/warehouse/manage/views.py
@@ -559,8 +559,7 @@ def __init__(self, request):
 
     @property
     def project_names(self):
-        projects = user_projects(self.request)["projects_owned"]
-        return [project.name for project in projects]
+        return sorted(project.name for project in self.request.user.projects)
 
     @property
     def default_response(self):


Test patch:
diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py
--- a/tests/unit/manage/test_views.py
+++ b/tests/unit/manage/test_views.py
@@ -1363,6 +1363,41 @@ def test_default_response(self, monkeypatch):
             "delete_macaroon_form": delete_macaroon_obj,
         }
 
+    def test_project_names(self, db_request):
+        user = UserFactory.create()
+        another_user = UserFactory.create()
+
+        db_request.user = user
+        db_request.find_service = lambda *a, **kw: pretend.stub()
+
+        # A project with a sole owner that is the user
+        with_sole_owner = ProjectFactory.create(name="foo")
+        RoleFactory.create(user=user, project=with_sole_owner, role_name="Owner")
+        RoleFactory.create(
+            user=another_user, project=with_sole_owner, role_name="Maintainer"
+        )
+
+        # A project with multiple owners, including the user
+        with_multiple_owners = ProjectFactory.create(name="bar")
+        RoleFactory.create(user=user, project=with_multiple_owners, role_name="Owner")
+        RoleFactory.create(
+            user=another_user, project=with_multiple_owners, role_name="Owner"
+        )
+
+        # A project with a sole owner that is not the user
+        not_an_owner = ProjectFactory.create(name="baz")
+        RoleFactory.create(user=user, project=not_an_owner, role_name="Maintainer")
+        RoleFactory.create(user=another_user, project=not_an_owner, role_name="Owner")
+
+        # A project that the user is neither owner nor maintainer of
+        neither_owner_nor_maintainer = ProjectFactory.create(name="quux")
+        RoleFactory.create(
+            user=another_user, project=neither_owner_nor_maintainer, role_name="Owner"
+        )
+
+        view = views.ProvisionMacaroonViews(db_request)
+        assert set(view.project_names) == {"foo", "bar", "baz"}
+
     def test_manage_macaroons(self, monkeypatch):
         request = pretend.stub(find_service=lambda *a, **kw: pretend.stub())
 
@@ -1412,10 +1447,10 @@ def test_create_macaroon_invalid_form(self, monkeypatch):
         )
         monkeypatch.setattr(views, "CreateMacaroonForm", create_macaroon_cls)
 
-        user_projects = pretend.call_recorder(
-            lambda r: {"projects_owned": [pretend.stub(name=pretend.stub())]}
+        project_names = [pretend.stub()]
+        monkeypatch.setattr(
+            views.ProvisionMacaroonViews, "project_names", project_names
         )
-        monkeypatch.setattr(views, "user_projects", user_projects)
 
         default_response = {"default": "response"}
         monkeypatch.setattr(
@@ -1458,11 +1493,10 @@ def test_create_macaroon(self, monkeypatch):
         )
         monkeypatch.setattr(views, "CreateMacaroonForm", create_macaroon_cls)
 
-        project_name = pretend.stub()
-        user_projects = pretend.call_recorder(
-            lambda r: {"projects_owned": [pretend.stub(name=project_name)]}
+        project_names = [pretend.stub()]
+        monkeypatch.setattr(
+            views.ProvisionMacaroonViews, "project_names", project_names
         )
-        monkeypatch.setattr(views, "user_projects", user_projects)
 
         default_response = {"default": "response"}
         monkeypatch.setattr(