diff --git a/tests/common/db/organizations.py b/tests/common/db/organizations.py
index 67a366aff659..6cfbaa01a428 100644
--- a/tests/common/db/organizations.py
+++ b/tests/common/db/organizations.py
@@ -14,8 +14,10 @@
import factory
import faker
+import packaging.utils
from warehouse.organizations.models import (
+ Namespace,
Organization,
OrganizationApplication,
OrganizationInvitation,
@@ -186,3 +188,18 @@ class Meta:
role_name = TeamProjectRoleType.Owner
project = factory.SubFactory(ProjectFactory)
team = factory.SubFactory(TeamFactory)
+
+
+class NamespaceFactory(WarehouseFactory):
+ class Meta:
+ model = Namespace
+
+ is_approved = True
+ created = factory.Faker(
+ "date_time_between_dates", datetime_start=datetime.datetime(2008, 1, 1)
+ )
+ name = factory.Faker("pystr", max_chars=12)
+ normalized_name = factory.LazyAttribute(
+ lambda o: packaging.utils.canonicalize_name(o.name)
+ )
+ owner = factory.SubFactory(OrganizationFactory)
diff --git a/tests/conftest.py b/tests/conftest.py
index 33cf262c69c0..ec32b4a65d3c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -59,7 +59,7 @@
from warehouse.oidc.interfaces import IOIDCPublisherService
from warehouse.oidc.utils import ACTIVESTATE_OIDC_ISSUER_URL, GITHUB_OIDC_ISSUER_URL
from warehouse.organizations import services as organization_services
-from warehouse.organizations.interfaces import IOrganizationService
+from warehouse.organizations.interfaces import INamespaceService, IOrganizationService
from warehouse.packaging import services as packaging_services
from warehouse.packaging.interfaces import IProjectService
from warehouse.subscriptions import services as subscription_services
@@ -153,6 +153,7 @@ def pyramid_services(
email_service,
metrics,
organization_service,
+ namespace_service,
subscription_service,
token_service,
user_service,
@@ -171,6 +172,7 @@ def pyramid_services(
services.register_service(email_service, IEmailSender, None, name="")
services.register_service(metrics, IMetricsService, None, name="")
services.register_service(organization_service, IOrganizationService, None, name="")
+ services.register_service(namespace_service, INamespaceService, None, name="")
services.register_service(subscription_service, ISubscriptionService, None, name="")
services.register_service(token_service, ITokenService, None, name="password")
services.register_service(token_service, ITokenService, None, name="email")
@@ -484,6 +486,11 @@ def organization_service(db_session):
return organization_services.DatabaseOrganizationService(db_session)
+@pytest.fixture
+def namespace_service(db_session):
+ return organization_services.DatabaseNamespaceService(db_session)
+
+
@pytest.fixture
def billing_service(app_config):
stripe.api_base = app_config.registry.settings["billing.api_base"]
diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py
index 8f1ddfba2620..c956b3a6d051 100644
--- a/tests/unit/admin/test_routes.py
+++ b/tests/unit/admin/test_routes.py
@@ -54,6 +54,12 @@ def test_includeme():
"/admin/organization_applications/{organization_application_id}/decline/",
domain=warehouse,
),
+ pretend.call("admin.namespace.list", "/admin/namespaces/", domain=warehouse),
+ pretend.call(
+ "admin.namespace.detail",
+ "/admin/namespaces/{namespace_id}/",
+ domain=warehouse,
+ ),
pretend.call("admin.user.list", "/admin/users/", domain=warehouse),
pretend.call(
"admin.user.detail",
diff --git a/tests/unit/admin/views/test_namespaces.py b/tests/unit/admin/views/test_namespaces.py
new file mode 100644
index 000000000000..56b917eb4ac8
--- /dev/null
+++ b/tests/unit/admin/views/test_namespaces.py
@@ -0,0 +1,150 @@
+# 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 pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound
+
+from warehouse.admin.views import namespaces as views
+
+from ....common.db.organizations import NamespaceFactory
+
+
+class TestNamespaceList:
+
+ def test_no_query(self, db_request):
+ namespaces = sorted(
+ NamespaceFactory.create_batch(30), key=lambda n: n.normalized_name
+ )
+ result = views.namespace_list(db_request)
+
+ assert result == {"namespaces": namespaces[:25], "query": "", "terms": []}
+
+ def test_with_page(self, db_request):
+ db_request.GET["page"] = "2"
+ namespaces = sorted(
+ NamespaceFactory.create_batch(30), key=lambda n: n.normalized_name
+ )
+ result = views.namespace_list(db_request)
+
+ assert result == {"namespaces": namespaces[25:], "query": "", "terms": []}
+
+ def test_with_invalid_page(self):
+ request = pretend.stub(
+ flags=pretend.stub(enabled=lambda *a: False),
+ params={"page": "not an integer"},
+ )
+
+ with pytest.raises(HTTPBadRequest):
+ views.namespace_list(request)
+
+ def test_basic_query(self, db_request):
+ namespaces = sorted(
+ NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name
+ )
+ db_request.GET["q"] = namespaces[0].name
+ result = views.namespace_list(db_request)
+
+ assert namespaces[0] in result["namespaces"]
+ assert result["query"] == namespaces[0].name
+ assert result["terms"] == [namespaces[0].name]
+
+ def test_name_query(self, db_request):
+ namespaces = sorted(
+ NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name
+ )
+ db_request.GET["q"] = f"name:{namespaces[0].name}"
+ result = views.namespace_list(db_request)
+
+ assert namespaces[0] in result["namespaces"]
+ assert result["query"] == f"name:{namespaces[0].name}"
+ assert result["terms"] == [f"name:{namespaces[0].name}"]
+
+ def test_organization_query(self, db_request):
+ namespaces = sorted(
+ NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name
+ )
+ db_request.GET["q"] = f"organization:{namespaces[0].owner.name}"
+ result = views.namespace_list(db_request)
+
+ assert namespaces[0] in result["namespaces"]
+ assert result["query"] == f"organization:{namespaces[0].owner.name}"
+ assert result["terms"] == [f"organization:{namespaces[0].owner.name}"]
+
+ def test_is_approved_query(self, db_request):
+ namespaces = sorted(
+ NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name
+ )
+ namespaces[0].is_approved = True
+ namespaces[1].is_approved = True
+ namespaces[2].is_approved = False
+ namespaces[3].is_approved = False
+ namespaces[4].is_approved = False
+ db_request.GET["q"] = "is:approved"
+ result = views.namespace_list(db_request)
+
+ assert result == {
+ "namespaces": namespaces[:2],
+ "query": "is:approved",
+ "terms": ["is:approved"],
+ }
+
+ def test_is_pending_query(self, db_request):
+ namespaces = sorted(
+ NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name
+ )
+ namespaces[0].is_approved = True
+ namespaces[1].is_approved = True
+ namespaces[2].is_approved = False
+ namespaces[3].is_approved = False
+ namespaces[4].is_approved = False
+ db_request.GET["q"] = "is:pending"
+ result = views.namespace_list(db_request)
+
+ assert result == {
+ "namespaces": namespaces[2:],
+ "query": "is:pending",
+ "terms": ["is:pending"],
+ }
+
+ def test_is_invalid_query(self, db_request):
+ namespaces = sorted(
+ NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name
+ )
+ db_request.GET["q"] = "is:not-actually-a-valid-query"
+ result = views.namespace_list(db_request)
+
+ assert result == {
+ "namespaces": namespaces[:25],
+ "query": "is:not-actually-a-valid-query",
+ "terms": ["is:not-actually-a-valid-query"],
+ }
+
+
+class TestNamespaceDetail:
+ def test_detail(self, db_request):
+ namespaces = sorted(
+ NamespaceFactory.create_batch(5), key=lambda n: n.normalized_name
+ )
+ db_request.matchdict["namespace_id"] = str(namespaces[1].id)
+
+ assert views.namespace_detail(db_request) == {
+ "namespace": namespaces[1],
+ }
+
+ def test_detail_not_found(self, db_request):
+ NamespaceFactory.create_batch(5)
+ db_request.matchdict["namespace_id"] = "c6a1a66b-d1af-45fc-ae9f-21b36662c2ac"
+
+ with pytest.raises(HTTPNotFound):
+ views.namespace_detail(db_request)
diff --git a/tests/unit/api/test_simple.py b/tests/unit/api/test_simple.py
index ce05298afd91..6afc26119680 100644
--- a/tests/unit/api/test_simple.py
+++ b/tests/unit/api/test_simple.py
@@ -22,6 +22,11 @@
from warehouse.packaging.utils import API_VERSION, _valid_simple_detail_context
from ...common.db.accounts import UserFactory
+from ...common.db.organizations import (
+ NamespaceFactory,
+ OrganizationFactory,
+ OrganizationProjectFactory,
+)
from ...common.db.packaging import (
AlternateRepositoryFactory,
FileFactory,
@@ -221,6 +226,7 @@ def test_no_files_no_serial(self, db_request, content_type, renderer_override):
"files": [],
"versions": [],
"alternate-locations": [],
+ "namespace": None,
}
context = _update_context(context, content_type, renderer_override)
assert simple.simple_detail(project, db_request) == context
@@ -253,6 +259,92 @@ def test_no_files_with_serial(self, db_request, content_type, renderer_override)
"files": [],
"versions": [],
"alternate-locations": sorted(al.url for al in als),
+ "namespace": None,
+ }
+ context = _update_context(context, content_type, renderer_override)
+ assert simple.simple_detail(project, db_request) == context
+
+ assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id)
+ assert db_request.response.content_type == content_type
+ _assert_has_cors_headers(db_request.response.headers)
+
+ if renderer_override is not None:
+ assert db_request.override_renderer == renderer_override
+
+ @pytest.mark.parametrize(
+ ("content_type", "renderer_override"),
+ CONTENT_TYPE_PARAMS,
+ )
+ def test_with_namespaces_authorized(
+ self, db_request, content_type, renderer_override
+ ):
+ db_request.accept = content_type
+ org = OrganizationFactory.create()
+ namespace = NamespaceFactory.create(owner=org)
+ project = ProjectFactory.create(name=f"{namespace.name}-foo")
+ OrganizationProjectFactory.create(organization=org, project=project)
+ db_request.matchdict["name"] = project.normalized_name
+ user = UserFactory.create()
+ je = JournalEntryFactory.create(name=project.name, submitted_by=user)
+ als = [
+ AlternateRepositoryFactory.create(project=project),
+ AlternateRepositoryFactory.create(project=project),
+ ]
+
+ context = {
+ "meta": {"_last-serial": je.id, "api-version": API_VERSION},
+ "name": project.normalized_name,
+ "files": [],
+ "versions": [],
+ "alternate-locations": sorted(al.url for al in als),
+ "namespace": {
+ "prefix": namespace.normalized_name,
+ "open": namespace.is_open,
+ "authorized": True,
+ },
+ }
+ context = _update_context(context, content_type, renderer_override)
+ assert simple.simple_detail(project, db_request) == context
+
+ assert db_request.response.headers["X-PyPI-Last-Serial"] == str(je.id)
+ assert db_request.response.content_type == content_type
+ _assert_has_cors_headers(db_request.response.headers)
+
+ if renderer_override is not None:
+ assert db_request.override_renderer == renderer_override
+
+ @pytest.mark.parametrize(
+ ("content_type", "renderer_override"),
+ CONTENT_TYPE_PARAMS,
+ )
+ def test_with_namespaces_not_authorized(
+ self, db_request, content_type, renderer_override
+ ):
+ db_request.accept = content_type
+ org = OrganizationFactory.create()
+ namespace = NamespaceFactory.create(owner=org)
+ project = ProjectFactory.create(name=f"{namespace.name}-foo")
+ project2 = ProjectFactory.create(name=f"{namespace.name}-foo2")
+ OrganizationProjectFactory.create(organization=org, project=project2)
+ db_request.matchdict["name"] = project.normalized_name
+ user = UserFactory.create()
+ je = JournalEntryFactory.create(name=project.name, submitted_by=user)
+ als = [
+ AlternateRepositoryFactory.create(project=project),
+ AlternateRepositoryFactory.create(project=project),
+ ]
+
+ context = {
+ "meta": {"_last-serial": je.id, "api-version": API_VERSION},
+ "name": project.normalized_name,
+ "files": [],
+ "versions": [],
+ "alternate-locations": sorted(al.url for al in als),
+ "namespace": {
+ "prefix": namespace.normalized_name,
+ "open": namespace.is_open,
+ "authorized": False,
+ },
}
context = _update_context(context, content_type, renderer_override)
assert simple.simple_detail(project, db_request) == context
@@ -305,6 +397,7 @@ def test_with_files_no_serial(self, db_request, content_type, renderer_override)
for f in files
],
"alternate-locations": [],
+ "namespace": None,
}
context = _update_context(context, content_type, renderer_override)
assert simple.simple_detail(project, db_request) == context
@@ -357,6 +450,7 @@ def test_with_files_with_serial(self, db_request, content_type, renderer_overrid
for f in files
],
"alternate-locations": [],
+ "namespace": None,
}
context = _update_context(context, content_type, renderer_override)
assert simple.simple_detail(project, db_request) == context
@@ -454,6 +548,7 @@ def test_with_files_with_version_multi_digit(
for f in files
],
"alternate-locations": [],
+ "namespace": None,
}
context = _update_context(context, content_type, renderer_override)
assert simple.simple_detail(project, db_request) == context
@@ -486,6 +581,7 @@ def test_with_files_quarantined_omitted_from_index(
"files": [],
"versions": [],
"alternate-locations": [],
+ "namespace": None,
}
context = _update_context(context, content_type, renderer_override)
@@ -606,6 +702,7 @@ def route_url(route, **kw):
for f in files
],
"alternate-locations": [],
+ "namespace": None,
}
context = _update_context(context, content_type, renderer_override)
diff --git a/tests/unit/manage/test_forms.py b/tests/unit/manage/test_forms.py
index ba0f09bd6095..d800924c102d 100644
--- a/tests/unit/manage/test_forms.py
+++ b/tests/unit/manage/test_forms.py
@@ -1108,3 +1108,34 @@ def test_validate(self, pyramid_request, name, errors):
# NOTE(jleightcap): testing with Regexp validators returns raw LazyString
# objects in the error dict's values. Just assert on keys.
assert list(form.errors.keys()) == errors
+
+
+class TestRequestOrganizationNamespaceForm:
+
+ @pytest.mark.parametrize(
+ ("name", "errors", "existing"),
+ [
+ ("", ["name"], False),
+ (" namespace ", ["name"], False),
+ (".namespace", ["name"], False),
+ ("namespace-", ["name"], False),
+ ("namespace", ["name"], True),
+ ("namespace", [], False),
+ ],
+ )
+ def test_validate(self, pyramid_request, name, errors, existing):
+ pyramid_request.POST = MultiDict({"name": name})
+ namespace_service = pretend.stub(
+ get_namespace=lambda name: pretend.stub() if existing else None,
+ )
+
+ form = forms.RequestOrganizationNamespaceForm(
+ pyramid_request.POST,
+ namespace_service=namespace_service,
+ )
+
+ assert form.namespace_service is namespace_service
+ assert not form.validate() if errors else form.validate(), str(form.errors)
+ # NOTE(jleightcap): testing with Regexp validators returns raw LazyString
+ # objects in the error dict's values. Just assert on keys.
+ assert list(form.errors.keys()) == errors
diff --git a/tests/unit/manage/views/test_organizations.py b/tests/unit/manage/views/test_organizations.py
index f65bed0e08f4..8f07adbb3902 100644
--- a/tests/unit/manage/views/test_organizations.py
+++ b/tests/unit/manage/views/test_organizations.py
@@ -22,6 +22,7 @@
from tests.common.db.accounts import EmailFactory, UserFactory
from tests.common.db.organizations import (
+ NamespaceFactory,
OrganizationEventFactory,
OrganizationFactory,
OrganizationInvitationFactory,
@@ -3017,3 +3018,93 @@ def test_raises_404_with_out_of_range_page(self, db_request):
with pytest.raises(HTTPNotFound):
assert org_views.manage_organization_history(organization, db_request)
+
+
+class TestManageOrganizationNamespaces:
+ @pytest.mark.usefixtures("_enable_organizations")
+ def test_manage_organization_namespaces(
+ self,
+ db_request,
+ pyramid_user,
+ organization_service,
+ monkeypatch,
+ ):
+ organization = OrganizationFactory.create()
+ organization.namespaces = [NamespaceFactory.create()]
+
+ db_request.POST = MultiDict()
+
+ view = org_views.ManageOrganizationNamespacesViews(organization, db_request)
+ result = view.manage_organization_namespaces()
+ form = result["request_organization_namespace_form"]
+
+ assert view.request == db_request
+ assert view.organization_service == organization_service
+ assert result == {
+ "organization": organization,
+ "request_organization_namespace_form": form,
+ }
+
+ @pytest.mark.usefixtures("_enable_organizations")
+ def test_request_namespace(
+ self,
+ db_request,
+ pyramid_user,
+ namespace_service,
+ monkeypatch,
+ ):
+ organization = OrganizationFactory.create()
+ organization.namespaces = [NamespaceFactory.create()]
+
+ db_request.POST = MultiDict({"name": "my-ns"})
+
+ OrganizationRoleFactory.create(
+ organization=organization, user=db_request.user, role_name="Owner"
+ )
+
+ def request_namespace(name, *args, **kwargs):
+ ns = NamespaceFactory.create(name=name)
+ organization.namespaces.append(ns)
+ return ns
+
+ monkeypatch.setattr(namespace_service, "request_namespace", request_namespace)
+
+ view = org_views.ManageOrganizationNamespacesViews(organization, db_request)
+ result = view.request_organization_namespace()
+
+ assert isinstance(result, HTTPSeeOther)
+ assert result.headers["Location"] == db_request.path
+ assert len(organization.namespaces) == 2
+ assert organization.namespaces[-1].name == "my-ns"
+
+ @pytest.mark.usefixtures("_enable_organizations")
+ def test_request_namespace_invalid(
+ self,
+ db_request,
+ pyramid_user,
+ namespace_service,
+ monkeypatch,
+ ):
+ organization = OrganizationFactory.create()
+ organization.namespaces = [NamespaceFactory.create()]
+
+ OrganizationRoleFactory.create(
+ organization=organization, user=db_request.user, role_name="Owner"
+ )
+
+ db_request.POST = MultiDict({"name": organization.namespaces[0].name})
+
+ view = org_views.ManageOrganizationNamespacesViews(organization, db_request)
+ result = view.request_organization_namespace()
+ form = result["request_organization_namespace_form"]
+
+ assert view.request == db_request
+ assert view.namespace_service == namespace_service
+ assert result == {
+ "organization": organization,
+ "request_organization_namespace_form": form,
+ }
+ assert form.name.errors == [
+ "This namespace has already been requested. Choose a different namespace."
+ ]
+ assert len(organization.namespaces) == 1
diff --git a/tests/unit/organizations/test_init.py b/tests/unit/organizations/test_init.py
index 4904040b1c45..f251cdf0c4b5 100644
--- a/tests/unit/organizations/test_init.py
+++ b/tests/unit/organizations/test_init.py
@@ -15,8 +15,11 @@
from celery.schedules import crontab
from warehouse import organizations
-from warehouse.organizations.interfaces import IOrganizationService
-from warehouse.organizations.services import database_organization_factory
+from warehouse.organizations.interfaces import INamespaceService, IOrganizationService
+from warehouse.organizations.services import (
+ database_namespace_factory,
+ database_organization_factory,
+)
from warehouse.organizations.tasks import (
delete_declined_organizations,
update_organization_invitation_status,
@@ -36,6 +39,7 @@ def test_includeme():
assert config.register_service_factory.calls == [
pretend.call(database_organization_factory, IOrganizationService),
+ pretend.call(database_namespace_factory, INamespaceService),
]
assert config.add_periodic_task.calls == [
diff --git a/tests/unit/organizations/test_models.py b/tests/unit/organizations/test_models.py
index a1418cf2a2b5..4aa940c53a73 100644
--- a/tests/unit/organizations/test_models.py
+++ b/tests/unit/organizations/test_models.py
@@ -13,7 +13,7 @@
import pretend
import pytest
-from pyramid.authorization import Allow
+from pyramid.authorization import Allow, Authenticated
from pyramid.httpexceptions import HTTPPermanentRedirect
from pyramid.location import lineage
@@ -25,6 +25,7 @@
)
from ...common.db.organizations import (
+ NamespaceFactory as DBNamespaceFactory,
OrganizationFactory as DBOrganizationFactory,
OrganizationNameCatalogFactory as DBOrganizationNameCatalogFactory,
OrganizationRoleFactory as DBOrganizationRoleFactory,
@@ -138,6 +139,7 @@ def test_acl(self, db_session):
Permissions.OrganizationsBillingManage,
Permissions.OrganizationProjectsAdd,
Permissions.OrganizationProjectsRemove,
+ Permissions.OrganizationNamespaceManage,
],
),
(
@@ -151,6 +153,7 @@ def test_acl(self, db_session):
Permissions.OrganizationsBillingManage,
Permissions.OrganizationProjectsAdd,
Permissions.OrganizationProjectsRemove,
+ Permissions.OrganizationNamespaceManage,
],
),
],
@@ -187,6 +190,7 @@ def test_acl(self, db_session):
Permissions.OrganizationTeamsRead,
Permissions.OrganizationTeamsManage,
Permissions.OrganizationProjectsAdd,
+ Permissions.OrganizationNamespaceManage,
],
),
(
@@ -197,6 +201,7 @@ def test_acl(self, db_session):
Permissions.OrganizationTeamsRead,
Permissions.OrganizationTeamsManage,
Permissions.OrganizationProjectsAdd,
+ Permissions.OrganizationNamespaceManage,
],
),
],
@@ -355,6 +360,7 @@ def test_acl(self, db_session):
Permissions.OrganizationsBillingManage,
Permissions.OrganizationProjectsAdd,
Permissions.OrganizationProjectsRemove,
+ Permissions.OrganizationNamespaceManage,
],
),
(
@@ -368,6 +374,7 @@ def test_acl(self, db_session):
Permissions.OrganizationsBillingManage,
Permissions.OrganizationProjectsAdd,
Permissions.OrganizationProjectsRemove,
+ Permissions.OrganizationNamespaceManage,
],
),
],
@@ -404,6 +411,7 @@ def test_acl(self, db_session):
Permissions.OrganizationTeamsRead,
Permissions.OrganizationTeamsManage,
Permissions.OrganizationProjectsAdd,
+ Permissions.OrganizationNamespaceManage,
],
),
(
@@ -414,6 +422,7 @@ def test_acl(self, db_session):
Permissions.OrganizationTeamsRead,
Permissions.OrganizationTeamsManage,
Permissions.OrganizationProjectsAdd,
+ Permissions.OrganizationNamespaceManage,
],
),
],
@@ -491,3 +500,123 @@ def test_manageable_subscription_none(self, db_session):
)
assert organization.active_subscription is None
assert organization.manageable_subscription is None
+
+
+class TestNamespace:
+ def test_acl(self, db_session):
+ organization = DBOrganizationFactory.create()
+ namespace = DBNamespaceFactory.create(owner=organization)
+ owner1 = DBOrganizationRoleFactory.create(organization=organization)
+ owner2 = DBOrganizationRoleFactory.create(organization=organization)
+ DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.BillingManager
+ )
+ DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.BillingManager
+ )
+ account_mgr1 = DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.Manager
+ )
+ account_mgr2 = DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.Manager
+ )
+ DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.Member
+ )
+ DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.Member
+ )
+
+ acls = []
+ for location in lineage(namespace):
+ try:
+ acl = location.__acl__
+ except AttributeError:
+ continue
+
+ if acl and callable(acl):
+ acl = acl()
+
+ acls.extend(acl)
+
+ assert acls == sorted(
+ [
+ (Allow, f"user:{owner1.user.id}", [Permissions.NamespaceProjectsAdd]),
+ (Allow, f"user:{owner2.user.id}", [Permissions.NamespaceProjectsAdd]),
+ ],
+ key=lambda x: x[1],
+ ) + sorted(
+ [
+ (
+ Allow,
+ f"user:{account_mgr1.user.id}",
+ [Permissions.NamespaceProjectsAdd],
+ ),
+ (
+ Allow,
+ f"user:{account_mgr2.user.id}",
+ [Permissions.NamespaceProjectsAdd],
+ ),
+ ],
+ key=lambda x: x[1],
+ )
+
+ def test_acl_open(self, db_session):
+ organization = DBOrganizationFactory.create()
+ namespace = DBNamespaceFactory.create(owner=organization, is_open=True)
+ owner1 = DBOrganizationRoleFactory.create(organization=organization)
+ owner2 = DBOrganizationRoleFactory.create(organization=organization)
+ DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.BillingManager
+ )
+ DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.BillingManager
+ )
+ account_mgr1 = DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.Manager
+ )
+ account_mgr2 = DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.Manager
+ )
+ DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.Member
+ )
+ DBOrganizationRoleFactory.create(
+ organization=organization, role_name=OrganizationRoleType.Member
+ )
+
+ acls = []
+ for location in lineage(namespace):
+ try:
+ acl = location.__acl__
+ except AttributeError:
+ continue
+
+ if acl and callable(acl):
+ acl = acl()
+
+ acls.extend(acl)
+
+ assert acls == sorted(
+ [
+ (Allow, f"user:{owner1.user.id}", [Permissions.NamespaceProjectsAdd]),
+ (Allow, f"user:{owner2.user.id}", [Permissions.NamespaceProjectsAdd]),
+ ],
+ key=lambda x: x[1],
+ ) + sorted(
+ [
+ (
+ Allow,
+ f"user:{account_mgr1.user.id}",
+ [Permissions.NamespaceProjectsAdd],
+ ),
+ (
+ Allow,
+ f"user:{account_mgr2.user.id}",
+ [Permissions.NamespaceProjectsAdd],
+ ),
+ ],
+ key=lambda x: x[1],
+ ) + [
+ (Allow, Authenticated, [Permissions.NamespaceProjectsAdd])
+ ]
diff --git a/tests/unit/organizations/test_services.py b/tests/unit/organizations/test_services.py
index 6b8e235b4c5e..6fa71051c48f 100644
--- a/tests/unit/organizations/test_services.py
+++ b/tests/unit/organizations/test_services.py
@@ -18,7 +18,7 @@
from warehouse.accounts.models import User
from warehouse.events.tags import EventTag
from warehouse.organizations import services
-from warehouse.organizations.interfaces import IOrganizationService
+from warehouse.organizations.interfaces import INamespaceService, IOrganizationService
from warehouse.organizations.models import (
Organization,
OrganizationInvitation,
@@ -38,6 +38,7 @@
from warehouse.subscriptions.models import StripeSubscription
from ...common.db.organizations import (
+ NamespaceFactory,
OrganizationApplicationFactory,
OrganizationFactory,
OrganizationInvitationFactory,
@@ -916,3 +917,41 @@ def test_delete_team_project_role(self, organization_service):
organization_service.delete_team_project_role(team_project_role.id)
assert organization_service.get_team_role(team_project_role_id) is None
+
+
+def test_database_namespace_factory():
+ db = pretend.stub()
+ context = pretend.stub()
+ request = pretend.stub(db=db)
+
+ service = services.database_namespace_factory(context, request)
+ assert service.db is db
+
+
+class TestDatabaseNamespaceService:
+ def test_verify_service(self):
+ assert verifyClass(INamespaceService, services.DatabaseNamespaceService)
+
+ def test_service_creation(self):
+ session = pretend.stub()
+ service = services.DatabaseNamespaceService(session)
+
+ assert service.db is session
+
+ def test_get_namespace(self, db_session):
+ ns = NamespaceFactory.create()
+ service = services.DatabaseNamespaceService(db_session)
+ assert service.get_namespace(ns.name).id == ns.id
+
+ def test_get_namespace_with_children(self, db_session):
+ ns = NamespaceFactory.create()
+ NamespaceFactory.create(name=f"{ns.name}-child", parent=ns, owner=ns.owner)
+ service = services.DatabaseNamespaceService(db_session)
+ assert service.get_namespace(ns.name).id == ns.id
+
+ def test_request_namespace(self, db_session):
+ org = OrganizationFactory.create()
+ service = services.DatabaseNamespaceService(db_session)
+ ns = service.request_namespace(name="foo", organization_id=org.id)
+ assert ns.name == "foo"
+ assert ns.owner_id == org.id
diff --git a/tests/unit/packaging/test_services.py b/tests/unit/packaging/test_services.py
index 83dbbb2e60b4..d57b94e5f77d 100644
--- a/tests/unit/packaging/test_services.py
+++ b/tests/unit/packaging/test_services.py
@@ -20,6 +20,7 @@
import pretend
import pytest
+from pyramid.httpexceptions import HTTPForbidden
from zope.interface.verify import verifyClass
import warehouse.packaging.services
@@ -51,7 +52,12 @@
project_service_factory,
)
-from ...common.db.packaging import ProhibitedProjectFactory, ProjectFactory
+from ...common.db.accounts import UserFactory
+from ...common.db.organizations import NamespaceFactory
+from ...common.db.packaging import (
+ ProhibitedProjectFactory,
+ ProjectFactory,
+)
class TestLocalFileStorage:
@@ -1056,6 +1062,47 @@ def test_check_project_name_ok(self, db_session):
# Should not raise any exception
service.check_project_name("foo")
+ def test_check_namespaces_ok(self, db_session):
+ NamespaceFactory.create(name="foo")
+
+ request = pretend.stub()
+
+ service = ProjectService(session=db_session)
+ service.check_namespaces(request, "bar")
+ service.check_namespaces(request, "bar-foo")
+
+ def test_check_namespaces_no_permissions(
+ self, pyramid_config, db_request, db_session
+ ):
+ user = UserFactory.create()
+ pyramid_config.testing_securitypolicy(identity=user, permissive=False)
+
+ NamespaceFactory.create(name="foo")
+
+ service = ProjectService(session=db_session)
+ with pytest.raises(HTTPForbidden):
+ service.check_namespaces(db_request, "foo")
+ with pytest.raises(HTTPForbidden):
+ service.check_namespaces(db_request, "foo-bar")
+ with pytest.raises(HTTPForbidden):
+ service.check_namespaces(db_request, "foo.bar")
+ with pytest.raises(HTTPForbidden):
+ service.check_namespaces(db_request, "Foo-Bar")
+
+ def test_check_namespaces_with_permission(
+ self, pyramid_config, db_request, db_session
+ ):
+ user = UserFactory.create()
+ pyramid_config.testing_securitypolicy(identity=user, permissive=True)
+
+ NamespaceFactory.create(name="foo")
+
+ service = ProjectService(session=db_session)
+ service.check_namespaces(db_request, "foo")
+ service.check_namespaces(db_request, "foo-bar")
+ service.check_namespaces(db_request, "foo.bar")
+ service.check_namespaces(db_request, "Foo-Bar")
+
def test_project_service_factory():
db = pretend.stub()
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 4835bb8dec9b..ff8aa8bfad90 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -576,6 +576,8 @@ def test_root_factory_access_control_list():
Permissions.AdminObservationsWrite,
Permissions.AdminOrganizationsRead,
Permissions.AdminOrganizationsWrite,
+ Permissions.AdminNamespacesRead,
+ Permissions.AdminNamespacesWrite,
Permissions.AdminProhibitedEmailDomainsRead,
Permissions.AdminProhibitedEmailDomainsWrite,
Permissions.AdminProhibitedProjectsRead,
@@ -608,6 +610,7 @@ def test_root_factory_access_control_list():
Permissions.AdminObservationsRead,
Permissions.AdminObservationsWrite,
Permissions.AdminOrganizationsRead,
+ Permissions.AdminNamespacesRead,
Permissions.AdminProhibitedEmailDomainsRead,
Permissions.AdminProhibitedProjectsRead,
Permissions.AdminProhibitedUsernameRead,
@@ -634,6 +637,7 @@ def test_root_factory_access_control_list():
Permissions.AdminObservationsRead,
Permissions.AdminObservationsWrite,
Permissions.AdminOrganizationsRead,
+ Permissions.AdminNamespacesRead,
Permissions.AdminProhibitedEmailDomainsRead,
Permissions.AdminProhibitedProjectsRead,
Permissions.AdminProhibitedUsernameRead,
diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py
index a46221a109ba..1a2c20ca5fc3 100644
--- a/tests/unit/test_routes.py
+++ b/tests/unit/test_routes.py
@@ -315,6 +315,13 @@ def add_redirect_rule(*args, **kwargs):
traverse="/{organization_name}",
domain=warehouse,
),
+ pretend.call(
+ "manage.organization.namespaces",
+ "/manage/organization/{organization_name}/namespaces/",
+ factory="warehouse.organizations.models:OrganizationFactory",
+ traverse="/{organization_name}",
+ domain=warehouse,
+ ),
pretend.call(
"manage.organization.roles",
"/manage/organization/{organization_name}/people/",
diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py
index 0cff26f3ee25..e0e9e360e010 100644
--- a/warehouse/admin/routes.py
+++ b/warehouse/admin/routes.py
@@ -50,6 +50,10 @@ def includeme(config):
"/admin/organization_applications/{organization_application_id}/decline/",
domain=warehouse,
)
+ config.add_route("admin.namespace.list", "/admin/namespaces/", domain=warehouse)
+ config.add_route(
+ "admin.namespace.detail", "/admin/namespaces/{namespace_id}/", domain=warehouse
+ )
# User related Admin pages
config.add_route("admin.user.list", "/admin/users/", domain=warehouse)
diff --git a/warehouse/admin/templates/admin/base.html b/warehouse/admin/templates/admin/base.html
index 05cc6209dfff..8c3761462222 100644
--- a/warehouse/admin/templates/admin/base.html
+++ b/warehouse/admin/templates/admin/base.html
@@ -128,6 +128,11 @@
Organizations
+
+
+ Namespaces
+
+
Users
diff --git a/warehouse/admin/templates/admin/namespaces/detail.html b/warehouse/admin/templates/admin/namespaces/detail.html
new file mode 100644
index 000000000000..5af6b2a52788
--- /dev/null
+++ b/warehouse/admin/templates/admin/namespaces/detail.html
@@ -0,0 +1,79 @@
+{#
+ # 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.
+ -#}
+ {% extends "admin/base.html" %}
+
+ {% block title %}{{ namespace.name }}{% endblock %}
+
+ {% block breadcrumb %}
+ Namespaces
+ {{ namespace.name }}
+ {% endblock %}
+
+ {% block content %}
+
+
+
+
+
+
+
+
+ Created on {{ namespace.created|format_date() }}
+
+
+
+
+
+
+
+
+
+
+ {% endblock %}
+
\ No newline at end of file
diff --git a/warehouse/admin/templates/admin/namespaces/list.html b/warehouse/admin/templates/admin/namespaces/list.html
new file mode 100644
index 000000000000..0e261e9b4aeb
--- /dev/null
+++ b/warehouse/admin/templates/admin/namespaces/list.html
@@ -0,0 +1,95 @@
+{#
+ # 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.
+ -#}
+ {% extends "admin/base.html" %}
+
+ {% import "admin/utils/pagination.html" as pagination %}
+
+ {% block title %}Namespaces{% endblock %}
+
+ {% block breadcrumb %}
+ Namespaces
+ {% endblock %}
+
+ {% block content %}
+
+
+
+ {% endblock content %}
+
\ No newline at end of file
diff --git a/warehouse/admin/views/namespaces.py b/warehouse/admin/views/namespaces.py
new file mode 100644
index 000000000000..8e7256c936f2
--- /dev/null
+++ b/warehouse/admin/views/namespaces.py
@@ -0,0 +1,133 @@
+# 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 shlex
+
+from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage
+from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound
+from pyramid.view import view_config
+from sqlalchemy import or_
+
+from warehouse.authnz import Permissions
+from warehouse.organizations.models import Namespace, Organization
+from warehouse.utils.paginate import paginate_url_factory
+
+
+@view_config(
+ route_name="admin.namespace.list",
+ renderer="admin/namespaces/list.html",
+ permission=Permissions.AdminNamespacesRead,
+ uses_session=True,
+)
+def namespace_list(request):
+ q = request.params.get("q", "")
+ terms = shlex.split(q)
+
+ try:
+ page_num = int(request.params.get("page", 1))
+ except ValueError:
+ raise HTTPBadRequest("'page' must be an integer.") from None
+
+ query = (
+ request.db.query(Namespace)
+ .join(Namespace.owner)
+ .order_by(Namespace.normalized_name)
+ )
+
+ if q:
+ filters: list = []
+ for term in terms:
+ # Examples:
+ # - search individual words or "whole phrase" in any field
+ # - name:psf
+ # - org:python
+ # - organization:python
+ # - is:approved
+ # - is:pending
+ try:
+ field, value = term.lower().split(":", 1)
+ except ValueError:
+ field, value = "", term
+ if field == "name":
+ # Add filter for `name` or `normalized_name` fields.
+ filters.append(
+ [
+ Namespace.name.ilike(f"%{value}%"),
+ Namespace.normalized_name.ilike(f"%{value}%"),
+ ]
+ )
+ elif field == "org" or field == "organization":
+ # Add filter for `Organization.Name` or `Organization.normalized_name`
+ # field.
+ filters.append(
+ [
+ Organization.name.ilike(f"%{value}%"),
+ Organization.normalized_name.ilike(f"%{value}%"),
+ ]
+ )
+ elif field == "is":
+ # Add filter for `is_approved` field.
+ if "approved".startswith(value):
+ filters.append(Namespace.is_approved == True) # noqa: E712
+ elif "pending".startswith(value):
+ filters.append(Namespace.is_approved == False) # noqa: E712
+ else:
+ # Add filter for any field.
+ filters.append(
+ [
+ Namespace.name.ilike(f"%{term}%"),
+ Namespace.normalized_name.ilike(f"%{term}%"),
+ ]
+ )
+ # Use AND to add each filter. Use OR to combine subfilters.
+ for filter_or_subfilters in filters:
+ if isinstance(filter_or_subfilters, list):
+ # Add list of subfilters combined with OR.
+ filter_or_subfilters = filter_or_subfilters or [True]
+ query = query.filter(or_(False, *filter_or_subfilters))
+ else:
+ # Add single filter.
+ query = query.filter(filter_or_subfilters)
+
+ namespaces = SQLAlchemyORMPage(
+ query,
+ page=page_num,
+ items_per_page=25,
+ url_maker=paginate_url_factory(request),
+ )
+
+ return {"namespaces": namespaces, "query": q, "terms": terms}
+
+
+@view_config(
+ route_name="admin.namespace.detail",
+ require_methods=False,
+ renderer="admin/namespaces/detail.html",
+ permission=Permissions.AdminNamespacesRead,
+ has_translations=True,
+ uses_session=True,
+ require_csrf=True,
+ require_reauth=True,
+)
+def namespace_detail(request):
+ namespace = (
+ request.db.query(Namespace)
+ .join(Namespace.owner)
+ .filter(Namespace.id == request.matchdict["namespace_id"])
+ .first()
+ )
+ if namespace is None:
+ raise HTTPNotFound
+
+ return {
+ "namespace": namespace,
+ }
diff --git a/warehouse/authnz/_permissions.py b/warehouse/authnz/_permissions.py
index 62d60ac07088..aa6f75221644 100644
--- a/warehouse/authnz/_permissions.py
+++ b/warehouse/authnz/_permissions.py
@@ -60,6 +60,9 @@ class Permissions(StrEnum):
AdminOrganizationsRead = "admin:organizations:read"
AdminOrganizationsWrite = "admin:organizations:write"
+ AdminNamespacesRead = "admin:namespaces:read"
+ AdminNamespacesWrite = "admin:namespaces:write"
+
AdminProhibitedEmailDomainsRead = "admin:prohibited-email-domains:read"
AdminProhibitedEmailDomainsWrite = "admin:prohibited-email-domains:write"
@@ -111,7 +114,11 @@ class Permissions(StrEnum):
OrganizationProjectsAdd = "organizations:projects:add"
OrganizationProjectsRemove = "organizations:projects:remove" # TODO: unused?
OrganizationTeamsManage = "organizations:teams:manage"
+ OrganizationNamespaceManage = "organizations:namespaces:manage"
OrganizationTeamsRead = "organizations:teams:read"
+ # Namespace Permissions
+ NamespaceProjectsAdd = "namespaces:projects:add"
+
# Observer Permissions
SubmitMalwareObservation = "observer:submit-malware-observation"
diff --git a/warehouse/config.py b/warehouse/config.py
index 8f486547c8bb..236dcb67772d 100644
--- a/warehouse/config.py
+++ b/warehouse/config.py
@@ -86,6 +86,8 @@ class RootFactory:
Permissions.AdminObservationsWrite,
Permissions.AdminOrganizationsRead,
Permissions.AdminOrganizationsWrite,
+ Permissions.AdminNamespacesRead,
+ Permissions.AdminNamespacesWrite,
Permissions.AdminProhibitedEmailDomainsRead,
Permissions.AdminProhibitedEmailDomainsWrite,
Permissions.AdminProhibitedProjectsRead,
@@ -118,6 +120,7 @@ class RootFactory:
Permissions.AdminObservationsRead,
Permissions.AdminObservationsWrite,
Permissions.AdminOrganizationsRead,
+ Permissions.AdminNamespacesRead,
Permissions.AdminProhibitedEmailDomainsRead,
Permissions.AdminProhibitedProjectsRead,
Permissions.AdminProhibitedUsernameRead,
@@ -144,6 +147,7 @@ class RootFactory:
Permissions.AdminObservationsRead,
Permissions.AdminObservationsWrite,
Permissions.AdminOrganizationsRead,
+ Permissions.AdminNamespacesRead,
Permissions.AdminProhibitedEmailDomainsRead,
Permissions.AdminProhibitedProjectsRead,
Permissions.AdminProhibitedUsernameRead,
diff --git a/warehouse/events/tags.py b/warehouse/events/tags.py
index 22eaaaec92f9..e95190a4f121 100644
--- a/warehouse/events/tags.py
+++ b/warehouse/events/tags.py
@@ -190,6 +190,7 @@ class Organization(EventTagEnum):
TeamProjectRoleRemove = "organization:team_project_role:remove"
TeamRoleAdd = "organization:team_role:add"
TeamRoleRemove = "organization:team_role:remove"
+ NamespaceRequest = "organization:namespace_request"
class Team(EventTagEnum):
"""Tags for Organization events.
diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot
index 98cdb36f3e51..01e9ecf71c85 100644
--- a/warehouse/locale/messages.pot
+++ b/warehouse/locale/messages.pot
@@ -93,8 +93,8 @@ msgid ""
"different email."
msgstr ""
-#: warehouse/accounts/forms.py:409 warehouse/manage/forms.py:140
-#: warehouse/manage/forms.py:742
+#: warehouse/accounts/forms.py:409 warehouse/manage/forms.py:141
+#: warehouse/manage/forms.py:778
msgid "The name is too long. Choose a name with 100 characters or less."
msgstr ""
@@ -361,11 +361,11 @@ msgstr ""
msgid "Banner Preview"
msgstr ""
-#: warehouse/manage/forms.py:420
+#: warehouse/manage/forms.py:421
msgid "Choose an organization account name with 50 characters or less."
msgstr ""
-#: warehouse/manage/forms.py:428
+#: warehouse/manage/forms.py:429
msgid ""
"The organization account name is invalid. Organization account names must"
" be composed of letters, numbers, dots, hyphens and underscores. And must"
@@ -373,90 +373,98 @@ msgid ""
"organization account name."
msgstr ""
-#: warehouse/manage/forms.py:451
+#: warehouse/manage/forms.py:452
msgid ""
"This organization account name has already been used. Choose a different "
"organization account name."
msgstr ""
-#: warehouse/manage/forms.py:466
+#: warehouse/manage/forms.py:467
msgid ""
"You have already submitted an application for that name. Choose a "
"different organization account name."
msgstr ""
-#: warehouse/manage/forms.py:501
+#: warehouse/manage/forms.py:502
msgid "Select project"
msgstr ""
-#: warehouse/manage/forms.py:506 warehouse/oidc/forms/_core.py:29
+#: warehouse/manage/forms.py:507 warehouse/oidc/forms/_core.py:29
#: warehouse/oidc/forms/gitlab.py:57
msgid "Specify project name"
msgstr ""
-#: warehouse/manage/forms.py:510
+#: warehouse/manage/forms.py:511
msgid ""
"Start and end with a letter or numeral containing only ASCII numeric and "
"'.', '_' and '-'."
msgstr ""
-#: warehouse/manage/forms.py:517
+#: warehouse/manage/forms.py:518
msgid "This project name has already been used. Choose a different project name."
msgstr ""
-#: warehouse/manage/forms.py:590
+#: warehouse/manage/forms.py:591
msgid ""
"The organization name is too long. Choose a organization name with 100 "
"characters or less."
msgstr ""
-#: warehouse/manage/forms.py:602
+#: warehouse/manage/forms.py:603
msgid ""
"The organization URL is too long. Choose a organization URL with 400 "
"characters or less."
msgstr ""
-#: warehouse/manage/forms.py:608
+#: warehouse/manage/forms.py:609
msgid "The organization URL must start with http:// or https://"
msgstr ""
-#: warehouse/manage/forms.py:620
+#: warehouse/manage/forms.py:621
msgid ""
"The organization description is too long. Choose a organization "
"description with 400 characters or less."
msgstr ""
-#: warehouse/manage/forms.py:655
+#: warehouse/manage/forms.py:656
msgid "You have already submitted the maximum number of "
msgstr ""
-#: warehouse/manage/forms.py:684
+#: warehouse/manage/forms.py:685
msgid "Choose a team name with 50 characters or less."
msgstr ""
-#: warehouse/manage/forms.py:691
+#: warehouse/manage/forms.py:692
msgid ""
"The team name is invalid. Team names cannot start or end with a space, "
"period, underscore, hyphen, or slash. Choose a different team name."
msgstr ""
-#: warehouse/manage/forms.py:719
+#: warehouse/manage/forms.py:720
msgid "This team name has already been used. Choose a different team name."
msgstr ""
-#: warehouse/manage/forms.py:737
+#: warehouse/manage/forms.py:742
+msgid "The namespace name is invalid. Namespace must be valid project names."
+msgstr ""
+
+#: warehouse/manage/forms.py:759
+msgid "This namespace has already been requested. Choose a different namespace."
+msgstr ""
+
+#: warehouse/manage/forms.py:773
msgid "Specify your alternate repository name"
msgstr ""
-#: warehouse/manage/forms.py:751
+#: warehouse/manage/forms.py:787
msgid "Specify your alternate repository URL"
msgstr ""
-#: warehouse/manage/forms.py:756
+#: warehouse/manage/forms.py:792
msgid "The URL is too long. Choose a URL with 400 characters or less."
msgstr ""
-#: warehouse/manage/forms.py:770
+#: warehouse/manage/forms.py:806
msgid ""
"The description is too long. Choose a description with 400 characters or "
"less."
@@ -607,13 +615,13 @@ msgid ""
msgstr ""
#: warehouse/manage/views/__init__.py:2961
-#: warehouse/manage/views/organizations.py:893
+#: warehouse/manage/views/organizations.py:965
#, python-brace-format
msgid "User '${username}' already has an active invite. Please try again later."
msgstr ""
#: warehouse/manage/views/__init__.py:3026
-#: warehouse/manage/views/organizations.py:958
+#: warehouse/manage/views/organizations.py:1030
#, python-brace-format
msgid "Invitation sent to '${username}'"
msgstr ""
@@ -627,33 +635,33 @@ msgid "Invitation already expired."
msgstr ""
#: warehouse/manage/views/__init__.py:3102
-#: warehouse/manage/views/organizations.py:1145
+#: warehouse/manage/views/organizations.py:1217
#, python-brace-format
msgid "Invitation revoked from '${username}'."
msgstr ""
-#: warehouse/manage/views/organizations.py:869
+#: warehouse/manage/views/organizations.py:941
#, python-brace-format
msgid "User '${username}' already has ${role_name} role for organization"
msgstr ""
-#: warehouse/manage/views/organizations.py:880
+#: warehouse/manage/views/organizations.py:952
#, python-brace-format
msgid ""
"User '${username}' does not have a verified primary email address and "
"cannot be added as a ${role_name} for organization"
msgstr ""
-#: warehouse/manage/views/organizations.py:1040
-#: warehouse/manage/views/organizations.py:1082
+#: warehouse/manage/views/organizations.py:1112
+#: warehouse/manage/views/organizations.py:1154
msgid "Could not find organization invitation."
msgstr ""
-#: warehouse/manage/views/organizations.py:1050
+#: warehouse/manage/views/organizations.py:1122
msgid "Organization invitation could not be re-sent."
msgstr ""
-#: warehouse/manage/views/organizations.py:1098
+#: warehouse/manage/views/organizations.py:1170
#, python-brace-format
msgid "Expired invitation for '${username}' deleted."
msgstr ""
@@ -1538,6 +1546,7 @@ msgstr ""
#: warehouse/templates/manage/account/totp-provision.html:69
#: warehouse/templates/manage/account/webauthn-provision.html:44
#: warehouse/templates/manage/organization/activate_subscription.html:34
+#: warehouse/templates/manage/organization/namespaces.html:70
#: warehouse/templates/manage/organization/projects.html:128
#: warehouse/templates/manage/organization/projects.html:151
#: warehouse/templates/manage/organization/roles.html:270
@@ -3018,7 +3027,12 @@ msgstr ""
msgid "Teams"
msgstr ""
-#: warehouse/templates/includes/manage/manage-organization-menu.html:41
+#: warehouse/templates/includes/manage/manage-organization-menu.html:40
+#: warehouse/templates/manage/organization/namespaces.html:25
+msgid "Namespaces"
+msgstr ""
+
+#: warehouse/templates/includes/manage/manage-organization-menu.html:48
#: warehouse/templates/includes/manage/manage-project-menu.html:37
#: warehouse/templates/includes/manage/manage-team-menu.html:34
#: warehouse/templates/manage/account.html:505
@@ -3029,7 +3043,7 @@ msgstr ""
msgid "Security history"
msgstr ""
-#: warehouse/templates/includes/manage/manage-organization-menu.html:48
+#: warehouse/templates/includes/manage/manage-organization-menu.html:55
#: warehouse/templates/includes/manage/manage-project-menu.html:57
#: warehouse/templates/includes/manage/manage-team-menu.html:41
msgid "Settings"
@@ -3864,7 +3878,7 @@ msgid "Recent account activity"
msgstr ""
#: warehouse/templates/manage/account.html:780
-#: warehouse/templates/manage/organization/history.html:201
+#: warehouse/templates/manage/organization/history.html:212
#: warehouse/templates/manage/project/history.html:364
#: warehouse/templates/manage/team/history.html:108
#: warehouse/templates/manage/unverified-account.html:466
@@ -3872,8 +3886,8 @@ msgid "Event"
msgstr ""
#: warehouse/templates/manage/account.html:781
-#: warehouse/templates/manage/organization/history.html:202
-#: warehouse/templates/manage/organization/history.html:211
+#: warehouse/templates/manage/organization/history.html:213
+#: warehouse/templates/manage/organization/history.html:222
#: warehouse/templates/manage/project/history.html:365
#: warehouse/templates/manage/project/history.html:374
#: warehouse/templates/manage/team/history.html:109
@@ -3883,7 +3897,7 @@ msgid "Time"
msgstr ""
#: warehouse/templates/manage/account.html:782
-#: warehouse/templates/manage/organization/history.html:203
+#: warehouse/templates/manage/organization/history.html:214
#: warehouse/templates/manage/team/history.html:110
#: warehouse/templates/manage/unverified-account.html:468
msgid "Additional Info"
@@ -3895,13 +3909,13 @@ msgid "Date / time"
msgstr ""
#: warehouse/templates/manage/account.html:793
-#: warehouse/templates/manage/organization/history.html:215
+#: warehouse/templates/manage/organization/history.html:226
#: warehouse/templates/manage/unverified-account.html:479
msgid "Location Info"
msgstr ""
#: warehouse/templates/manage/account.html:795
-#: warehouse/templates/manage/organization/history.html:217
+#: warehouse/templates/manage/organization/history.html:228
#: warehouse/templates/manage/project/history.html:380
#: warehouse/templates/manage/team/history.html:124
#: warehouse/templates/manage/unverified-account.html:481
@@ -4229,7 +4243,7 @@ msgid "Any"
msgstr ""
#: warehouse/templates/manage/manage_base.html:582
-#: warehouse/templates/manage/organization/history.html:166
+#: warehouse/templates/manage/organization/history.html:177
#: warehouse/templates/manage/project/history.html:43
#: warehouse/templates/manage/project/history.html:97
#: warehouse/templates/manage/project/history.html:137
@@ -4242,7 +4256,7 @@ msgid "Added by:"
msgstr ""
#: warehouse/templates/manage/manage_base.html:584
-#: warehouse/templates/manage/organization/history.html:171
+#: warehouse/templates/manage/organization/history.html:182
#: warehouse/templates/manage/project/history.html:62
#: warehouse/templates/manage/project/history.html:128
#: warehouse/templates/manage/project/history.html:144
@@ -4280,6 +4294,7 @@ msgstr ""
msgid "Pending invitations"
msgstr ""
+#: warehouse/templates/manage/organization/namespaces.html:42
#: warehouse/templates/manage/organization/projects.html:55
#: warehouse/templates/manage/organizations.html:35
#: warehouse/templates/manage/organizations.html:96
@@ -4316,6 +4331,7 @@ msgstr ""
msgid "%(org_type)s"
msgstr ""
+#: warehouse/templates/manage/organization/namespaces.html:38
#: warehouse/templates/manage/organizations.html:78
#: warehouse/templates/manage/organizations.html:158
msgid "Request Submitted"
@@ -5421,56 +5437,65 @@ msgstr ""
msgid " %(username)s removed from %(team_name)s team"
msgstr ""
-#: warehouse/templates/manage/organization/history.html:132
+#: warehouse/templates/manage/organization/history.html:126
+#, python-format
+msgid "%(namespace_name)s namespace requested"
+msgstr ""
+
+#: warehouse/templates/manage/organization/history.html:136
msgid "Registered by:"
msgstr ""
-#: warehouse/templates/manage/organization/history.html:139
+#: warehouse/templates/manage/organization/history.html:143
#: warehouse/templates/manage/project/history.html:34
#: warehouse/templates/manage/team/history.html:71
msgid "Created by:"
msgstr ""
-#: warehouse/templates/manage/organization/history.html:144
+#: warehouse/templates/manage/organization/history.html:148
#: warehouse/templates/manage/project/history.html:322
#: warehouse/templates/manage/project/history.html:344
#: warehouse/templates/manage/team/history.html:76
msgid "Deleted by:"
msgstr ""
-#: warehouse/templates/manage/organization/history.html:149
+#: warehouse/templates/manage/organization/history.html:153
#: warehouse/templates/manage/team/history.html:81
msgid "Renamed by:"
msgstr ""
-#: warehouse/templates/manage/organization/history.html:154
+#: warehouse/templates/manage/organization/history.html:158
msgid "Approved by:"
msgstr ""
-#: warehouse/templates/manage/organization/history.html:159
+#: warehouse/templates/manage/organization/history.html:163
msgid "Declined by:"
msgstr ""
-#: warehouse/templates/manage/organization/history.html:176
+#: warehouse/templates/manage/organization/history.html:170
+msgid "Requested by:"
+msgstr ""
+
+#: warehouse/templates/manage/organization/history.html:187
#: warehouse/templates/manage/project/history.html:151
#: warehouse/templates/manage/project/history.html:198
#: warehouse/templates/manage/team/history.html:98
msgid "Changed by:"
msgstr ""
-#: warehouse/templates/manage/organization/history.html:181
-#: warehouse/templates/manage/organization/history.html:186
+#: warehouse/templates/manage/organization/history.html:192
+#: warehouse/templates/manage/organization/history.html:197
#: warehouse/templates/manage/project/history.html:158
#: warehouse/templates/manage/project/history.html:165
msgid "Invited by:"
msgstr ""
-#: warehouse/templates/manage/organization/history.html:191
+#: warehouse/templates/manage/organization/history.html:202
#: warehouse/templates/manage/project/history.html:172
msgid "Revoked by:"
msgstr ""
-#: warehouse/templates/manage/organization/history.html:198
+#: warehouse/templates/manage/organization/history.html:209
#: warehouse/templates/manage/project/history.html:361
#: warehouse/templates/manage/team/history.html:105
#, python-format
@@ -5486,6 +5511,43 @@ msgstr ""
msgid "Back to organizations"
msgstr ""
+#: warehouse/templates/manage/organization/namespaces.html:17
+msgid "Organization namespaces"
+msgstr ""
+
+#: warehouse/templates/manage/organization/namespaces.html:21
+#, python-format
+msgid "Manage '%(organization_name)s' namespaces"
+msgstr ""
+
+#: warehouse/templates/manage/organization/namespaces.html:38
+msgid "You will receive an email when the namespace has been approved"
+msgstr ""
+
+#: warehouse/templates/manage/organization/namespaces.html:52
+msgid ""
+"There are no namespaces in your organization, yet. Organization owners "
+"and managers can request new namespaces for the organization."
+msgstr ""
+
+#: warehouse/templates/manage/organization/namespaces.html:63
+msgid "Request namespace"
+msgstr ""
+
+#: warehouse/templates/manage/organization/namespaces.html:69
+msgid "️Namespace"
+msgstr ""
+
+#: warehouse/templates/manage/organization/namespaces.html:80
+msgid ""
+"Owners and managers of this organization can request a namespace for the "
+"organization."
+msgstr ""
+
+#: warehouse/templates/manage/organization/namespaces.html:85
+msgid "Request"
+msgstr ""
+
#: warehouse/templates/manage/organization/projects.html:17
msgid "Organization projects"
msgstr ""
diff --git a/warehouse/manage/forms.py b/warehouse/manage/forms.py
index cfe8293bf2dc..ad3abb5b1f78 100644
--- a/warehouse/manage/forms.py
+++ b/warehouse/manage/forms.py
@@ -11,6 +11,7 @@
# limitations under the License.
import json
+import re
import wtforms
@@ -726,6 +727,41 @@ class CreateTeamForm(SaveTeamForm):
__params__ = SaveTeamForm.__params__
+class RequestOrganizationNamespaceForm(wtforms.Form):
+ __params__ = ["name"]
+
+ name = wtforms.StringField(
+ validators=[
+ wtforms.validators.InputRequired(message="Specify namespace name"),
+ # the regexp below must match the CheckConstraint
+ # for the name field in organizations.models.Namespace
+ wtforms.validators.Regexp(
+ r"^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$",
+ flags=re.IGNORECASE,
+ message=_(
+ "The namespace name is invalid. Namespace must be valid "
+ "project names."
+ ),
+ ),
+ ]
+ )
+
+ def __init__(self, *args, namespace_service, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.namespace_service = namespace_service
+
+ def validate_name(self, field):
+ # Our name is only valid if there isn't already another namespace by
+ # that name.
+ if self.namespace_service.get_namespace(field.data) is not None:
+ raise wtforms.validators.ValidationError(
+ _(
+ "This namespace has already been requested. "
+ "Choose a different namespace."
+ )
+ )
+
+
class AddAlternateRepositoryForm(wtforms.Form):
"""Form to add an Alternate Repository Location for a Project."""
diff --git a/warehouse/manage/views/organizations.py b/warehouse/manage/views/organizations.py
index ee59addaa8ec..78dd04078b65 100644
--- a/warehouse/manage/views/organizations.py
+++ b/warehouse/manage/views/organizations.py
@@ -52,6 +52,7 @@
CreateOrganizationRoleForm,
CreateTeamForm,
OrganizationActivateBillingForm,
+ RequestOrganizationNamespaceForm,
SaveOrganizationForm,
SaveOrganizationNameForm,
TransferOrganizationProjectForm,
@@ -61,7 +62,7 @@
user_organizations,
user_projects,
)
-from warehouse.organizations import IOrganizationService
+from warehouse.organizations import INamespaceService, IOrganizationService
from warehouse.organizations.models import (
Organization,
OrganizationInvitationStatus,
@@ -846,6 +847,77 @@ def add_organization_project(self):
return HTTPSeeOther(self.request.path)
+@view_defaults(
+ route_name="manage.organization.namespaces",
+ context=Organization,
+ renderer="manage/organization/namespaces.html",
+ uses_session=True,
+ require_active_organization=True,
+ require_csrf=True,
+ require_methods=False,
+ permission=Permissions.OrganizationsManage,
+ has_translations=True,
+ require_reauth=True,
+)
+class ManageOrganizationNamespacesViews:
+ def __init__(self, organization, request):
+ self.organization = organization
+ self.request = request
+ self.user_service = request.find_service(IUserService, context=None)
+ self.organization_service = request.find_service(
+ IOrganizationService, context=None
+ )
+ self.namespace_service = request.find_service(INamespaceService, context=None)
+
+ @property
+ def default_response(self):
+ return {
+ "organization": self.organization,
+ "request_organization_namespace_form": RequestOrganizationNamespaceForm(
+ self.request.POST,
+ namespace_service=self.namespace_service,
+ ),
+ }
+
+ @view_config(request_method="GET", permission=Permissions.OrganizationsRead)
+ def manage_organization_namespaces(self):
+ return self.default_response
+
+ @view_config(
+ request_method="POST", permission=Permissions.OrganizationNamespaceManage
+ )
+ def request_organization_namespace(self):
+ # Get and validate form from default response.
+ default_response = self.default_response
+ form = default_response["request_organization_namespace_form"]
+ if not form.validate():
+ return default_response
+
+ # Create namespace request
+ namespace = self.namespace_service.request_namespace(
+ organization_id=self.organization.id, name=form.name.data
+ )
+
+ # Record events.
+ self.organization.record_event(
+ tag=EventTag.Organization.NamespaceRequest,
+ request=self.request,
+ additional={
+ "requested_by_user_id": str(self.request.user.id),
+ "namespace_name": namespace.name,
+ },
+ )
+
+ # Display notification message.
+ self.request.session.flash(
+ f"Request namespace {namespace.name!r} in {self.organization.name!r}",
+ queue="success",
+ )
+
+ # Refresh namespace list.
+ return HTTPSeeOther(self.request.path)
+
+
def _send_organization_invitation(request, organization, role_name, user):
organization_service = request.find_service(IOrganizationService, context=None)
token_service = request.find_service(ITokenService, name="email")
diff --git a/warehouse/migrations/versions/cd69005ab09c_add_namespace_support.py b/warehouse/migrations/versions/cd69005ab09c_add_namespace_support.py
new file mode 100644
index 000000000000..f31713e60c84
--- /dev/null
+++ b/warehouse/migrations/versions/cd69005ab09c_add_namespace_support.py
@@ -0,0 +1,107 @@
+# 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.
+"""
+add namespace support
+
+Revision ID: cd69005ab09c
+Revises: 6cac7b706953
+Create Date: 2025-02-19 12:38:52.758352
+"""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "cd69005ab09c"
+down_revision = "635b80625fc9"
+
+
+def upgrade():
+ op.create_table(
+ "project_namespaces",
+ sa.Column(
+ "is_approved", sa.Boolean(), server_default=sa.text("false"), nullable=False
+ ),
+ sa.Column(
+ "created", sa.DateTime(), server_default=sa.text("now()"), nullable=False
+ ),
+ sa.Column("name", sa.String(), nullable=False),
+ sa.Column("normalized_name", sa.String(), nullable=False),
+ sa.Column("owner_id", sa.UUID(), nullable=False),
+ sa.Column("parent_id", sa.UUID(), nullable=True),
+ sa.Column(
+ "is_open", sa.Boolean(), server_default=sa.text("false"), nullable=False
+ ),
+ sa.Column(
+ "is_hidden", sa.Boolean(), server_default=sa.text("false"), nullable=False
+ ),
+ sa.Column(
+ "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False
+ ),
+ sa.CheckConstraint(
+ "name ~* '^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$'::text",
+ name="project_namespaces_valid_name",
+ ),
+ sa.ForeignKeyConstraint(
+ ["parent_id"],
+ ["project_namespaces.id"],
+ ),
+ sa.ForeignKeyConstraint(
+ ["owner_id"],
+ ["organizations.id"],
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("name"),
+ sa.UniqueConstraint("normalized_name"),
+ )
+ op.create_index(
+ op.f("ix_project_namespaces_parent_id"),
+ "project_namespaces",
+ ["parent_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_project_namespaces_owner_id"),
+ "project_namespaces",
+ ["owner_id"],
+ unique=False,
+ )
+
+ op.execute(
+ """ CREATE OR REPLACE FUNCTION maintain_project_namespaces_normalized_name()
+ RETURNS TRIGGER AS $$
+ BEGIN
+ NEW.normalized_name := normalize_pep426_name(NEW.name);
+ RETURN NEW;
+ END;
+ $$
+ LANGUAGE plpgsql
+ """
+ )
+
+ op.execute(
+ """ CREATE TRIGGER project_namespaces_update_normalized_name
+ BEFORE INSERT OR UPDATE OF name ON project_namespaces
+ FOR EACH ROW
+ EXECUTE PROCEDURE maintain_project_namespaces_normalized_name()
+ """
+ )
+
+
+def downgrade():
+ op.drop_index(
+ op.f("ix_project_namespaces_owner_id"), table_name="project_namespaces"
+ )
+ op.drop_index(
+ op.f("ix_project_namespaces_parent_id"), table_name="project_namespaces"
+ )
+ op.drop_table("project_namespaces")
diff --git a/warehouse/organizations/__init__.py b/warehouse/organizations/__init__.py
index bfbff0e57dd1..d0d74198b8f1 100644
--- a/warehouse/organizations/__init__.py
+++ b/warehouse/organizations/__init__.py
@@ -12,8 +12,11 @@
from celery.schedules import crontab
-from warehouse.organizations.interfaces import IOrganizationService
-from warehouse.organizations.services import database_organization_factory
+from warehouse.organizations.interfaces import INamespaceService, IOrganizationService
+from warehouse.organizations.services import (
+ database_namespace_factory,
+ database_organization_factory,
+)
from warehouse.organizations.tasks import (
delete_declined_organizations,
update_organization_invitation_status,
@@ -25,6 +28,9 @@ def includeme(config):
# Register our organization service
config.register_service_factory(database_organization_factory, IOrganizationService)
+ # Register our namespace service
+ config.register_service_factory(database_namespace_factory, INamespaceService)
+
config.add_periodic_task(
crontab(minute="*/5"), update_organization_invitation_status
)
diff --git a/warehouse/organizations/interfaces.py b/warehouse/organizations/interfaces.py
index 31dc9cede4b9..3b62c44bfef3 100644
--- a/warehouse/organizations/interfaces.py
+++ b/warehouse/organizations/interfaces.py
@@ -274,3 +274,17 @@ def delete_team_project_role(team_project_role_id):
"""
Delete an team project role for a specified team project role id
"""
+
+
+class INamespaceService(Interface):
+ def get_namespace(name):
+ """
+ Return the namespace object that represents the given namespace, or None if
+ there is no namespace for that name.
+ """
+
+ def request_namespace(organization_id, name, is_open=False, is_hidden=False):
+ """
+ Request a new namespace, returning the object that represents this newly
+ requested namespace.
+ """
diff --git a/warehouse/organizations/models.py b/warehouse/organizations/models.py
index f2cbf2180c1d..e496b4d0054e 100644
--- a/warehouse/organizations/models.py
+++ b/warehouse/organizations/models.py
@@ -17,11 +17,12 @@
from uuid import UUID
-from pyramid.authorization import Allow
+from pyramid.authorization import Allow, Authenticated
from pyramid.httpexceptions import HTTPPermanentRedirect
from sqlalchemy import (
CheckConstraint,
Enum,
+ FetchedValue,
ForeignKey,
Index,
UniqueConstraint,
@@ -314,6 +315,7 @@ class Organization(OrganizationMixin, HasEvents, db.Model):
back_populates="organization",
order_by=lambda: Team.name.asc(),
)
+ namespaces: Mapped[list[Namespace]] = orm.relationship(back_populates="owner")
projects: Mapped[list[Project]] = relationship(
secondary=OrganizationProject.__table__,
back_populates="organization",
@@ -401,6 +403,7 @@ def __acl__(self):
# - Manage billing (Permissions.OrganizationsBillingManage)
# - Add project (Permissions.OrganizationProjectsAdd)
# - Remove project (Permissions.OrganizationProjectsRemove)
+ # - Request namespaces (OrganizationNamespaceManage)
# Disallowed:
# - (none)
acls.append(
@@ -415,6 +418,7 @@ def __acl__(self):
Permissions.OrganizationsBillingManage,
Permissions.OrganizationProjectsAdd,
Permissions.OrganizationProjectsRemove,
+ Permissions.OrganizationNamespaceManage,
],
)
)
@@ -428,6 +432,7 @@ def __acl__(self):
# - Create/delete team and add/remove members (OrganizationTeamsManage)
# - Add project (Permissions.OrganizationProjectsAdd)
# - Remove project (Permissions.OrganizationProjectsRemove)
+ # - Request namespaces (OrganizationNamespaceManage)
acls.append(
(
Allow,
@@ -445,6 +450,7 @@ def __acl__(self):
# - View team (Permissions.OrganizationTeamsRead)
# - Create/delete team and add/remove members (OrganizationTeamsManage)
# - Add project (Permissions.OrganizationProjectsAdd)
+ # - Request namespaces (OrganizationNamespaceManage)
# Disallowed:
# - Invite/remove organization member (Permissions.OrganizationsManage)
# - Manage billing (Permissions.OrganizationsBillingManage)
@@ -458,6 +464,7 @@ def __acl__(self):
Permissions.OrganizationTeamsRead,
Permissions.OrganizationTeamsManage,
Permissions.OrganizationProjectsAdd,
+ Permissions.OrganizationNamespaceManage,
],
)
)
@@ -473,6 +480,7 @@ def __acl__(self):
# - Manage billing (Permissions.OrganizationsBillingManage)
# - Add project (Permissions.OrganizationProjectsAdd)
# - Remove project (Permissions.OrganizationProjectsRemove)
+ # - Request namespaces (OrganizationNamespaceManage)
acls.append(
(
Allow,
@@ -736,3 +744,84 @@ def record_event(self, *, tag, request: Request = None, additional=None):
def __acl__(self):
return self.organization.__acl__()
+
+
+class Namespace(db.Model):
+ __tablename__ = "project_namespaces"
+ __table_args__ = (
+ CheckConstraint(
+ "name ~* '^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$'::text",
+ name="project_namespaces_valid_name",
+ ),
+ )
+
+ is_approved: Mapped[bool_false]
+ created: Mapped[datetime_now]
+ name: Mapped[str] = mapped_column(unique=True)
+ normalized_name: Mapped[str] = mapped_column(
+ unique=True,
+ server_default=FetchedValue(),
+ server_onupdate=FetchedValue(),
+ )
+ owner_id = mapped_column(
+ PG_UUID(as_uuid=True),
+ ForeignKey("organizations.id"),
+ index=True,
+ nullable=False,
+ )
+ owner: Mapped[Organization] = orm.relationship(back_populates="namespaces")
+ _parent_id = mapped_column(
+ "parent_id",
+ PG_UUID(as_uuid=True),
+ ForeignKey("project_namespaces.id"),
+ index=True,
+ nullable=True,
+ )
+ parent: Mapped[Namespace] = orm.relationship()
+ is_open: Mapped[bool_false]
+ is_hidden: Mapped[bool_false]
+
+ def is_project_authorized(self, project):
+ # To determine if a project is "authorized" to be part of this namespace
+ # we need to see if any owners of the project match the owner of this
+ # namespace.
+ for org_project in self.owner.projects:
+ if org_project.normalized_name == project.normalized_name:
+ return True
+
+ # Otherwise, the project is not authorized to be part of this namespace.
+ return False
+
+ def __acl__(self):
+ session = orm_session_from_obj(self)
+ acls = []
+
+ # Namespaces have a strong sense of ownership, unlike projects which can
+ # be owned by many identities, Namespaces can only be owned by a single
+ # identity, and that identity *must* be an organization.
+ #
+ # These are meant to map closely to the OrganizationProjectsAdd
+ # permission, so the same roles that have access to add a project to an
+ # organization also have access to use the name.
+ query = session.query(OrganizationRole).filter(
+ OrganizationRole.organization == self.owner,
+ OrganizationRole.role_name.in_(
+ [OrganizationRoleType.Owner, OrganizationRoleType.Manager]
+ ),
+ )
+ query = query.options(orm.lazyload(OrganizationRole.organization))
+ query = query.join(User).order_by(User.id.asc())
+ for role in sorted(
+ query.all(),
+ key=lambda x: [e.value for e in OrganizationRoleType].index(x.role_name),
+ ):
+ acls.append(
+ (Allow, f"user:{role.user.id}", [Permissions.NamespaceProjectsAdd])
+ )
+
+ # If the Namespace is "open", then any authenticated user is able to
+ # add a Project to this namespace.
+ if self.is_open:
+ acls.append((Allow, Authenticated, [Permissions.NamespaceProjectsAdd]))
+
+ return acls
diff --git a/warehouse/organizations/services.py b/warehouse/organizations/services.py
index b24857122383..5ea98d2a2d5a 100644
--- a/warehouse/organizations/services.py
+++ b/warehouse/organizations/services.py
@@ -24,8 +24,9 @@
send_new_organization_declined_email,
)
from warehouse.events.tags import EventTag
-from warehouse.organizations.interfaces import IOrganizationService
+from warehouse.organizations.interfaces import INamespaceService, IOrganizationService
from warehouse.organizations.models import (
+ Namespace,
Organization,
OrganizationApplication,
OrganizationInvitation,
@@ -779,3 +780,46 @@ def delete_team_project_role(self, team_project_role_id):
def database_organization_factory(context, request):
return DatabaseOrganizationService(request.db)
+
+
+@implementer(INamespaceService)
+class DatabaseNamespaceService:
+ def __init__(self, db_session):
+ self.db = db_session
+
+ def get_namespace(self, name):
+ """
+ Return the namespace object that represents the given namespace, or None if
+ there is no namespace for that name.
+
+ This will return the "parent" namespace, even for sub namespaces.
+ """
+ return (
+ self.db.query(Namespace)
+ .filter(
+ (
+ (Namespace.normalized_name == func.normalize_pep426_name(name))
+ | func.starts_with(
+ func.normalize_pep426_name(name),
+ func.concat(Namespace.normalized_name, "-"),
+ )
+ )
+ & (Namespace.parent == None) # noqa E711
+ )
+ .first()
+ )
+
+ def request_namespace(self, organization_id, name, is_open=False, is_hidden=False):
+ """
+ Request a new namespace, returning the object that represents this newly
+ requested namespace.
+ """
+ ns = Namespace(
+ name=name, owner_id=organization_id, is_open=is_open, is_hidden=is_hidden
+ )
+ self.db.add(ns)
+ return ns
+
+
+def database_namespace_factory(context, request):
+ return DatabaseNamespaceService(request.db)
diff --git a/warehouse/packaging/services.py b/warehouse/packaging/services.py
index a7713ae93b0a..98222e30f399 100644
--- a/warehouse/packaging/services.py
+++ b/warehouse/packaging/services.py
@@ -34,10 +34,12 @@
from zope.interface import implementer
from warehouse.admin.flags import AdminFlagValue
+from warehouse.authnz import Permissions
from warehouse.email import send_pending_trusted_publisher_invalidated_email
from warehouse.events.tags import EventTag
from warehouse.metrics import IMetricsService
from warehouse.oidc.models import PendingOIDCPublisher
+from warehouse.organizations.models import Namespace
from warehouse.packaging.interfaces import (
IDocsStorage,
IFileStorage,
@@ -455,6 +457,7 @@ def check_project_name(self, name: str) -> None:
if canonicalize_name(name) in STDLIB_PROHIBITED:
raise ProjectNameUnavailableStdlibError()
+ # Check if the project name matches one of the existing names.
if existing_project := self.db.scalars(
select(Project).where(
Project.normalized_name == func.normalize_pep426_name(name)
@@ -462,6 +465,7 @@ def check_project_name(self, name: str) -> None:
).first():
raise ProjectNameUnavailableExistingError(existing_project)
+ # Check if the project name matches one of the prohibited names.
if self.db.query(
exists().where(
ProhibitedProjectName.name == func.normalize_pep426_name(name)
@@ -469,6 +473,7 @@ def check_project_name(self, name: str) -> None:
).scalar():
raise ProjectNameUnavailableProhibitedError()
+ # Check if the project name is too similiar to an existing project name.
if similar_project_name := self.db.scalars(
select(Project.name).where(
func.ultranormalize_name(Project.name) == func.ultranormalize_name(name)
@@ -478,6 +483,38 @@ def check_project_name(self, name: str) -> None:
return None
+ def check_namespaces(self, request, name: str) -> None:
+ # TODO: This query will (without the first) give us a list of _all_ of
+ # the namespace reservations that match the desired project name,
+ # but we filter it down to just the most specific grant.
+ #
+ # This might be wrong? Rather than using the most specific grant
+ # we might want to look at _all_ of the grants? Either using all()
+ # semantics or any() semantics.
+ if ns := self.db.scalars(
+ select(Namespace)
+ .where(
+ (
+ (Namespace.normalized_name == func.normalize_pep426_name(name))
+ | func.starts_with(
+ func.normalize_pep426_name(name),
+ func.concat(Namespace.normalized_name, "-"),
+ )
+ )
+ & (Namespace.is_approved == True) # noqa E712
+ )
+ .order_by(func.length(Namespace.normalized_name).desc())
+ ).first():
+ # If we've found a namespace that matches this, so we'll check to
+ # see if we're allowed to upload to this namespace.
+ if not request.has_permission(Permissions.NamespaceProjectsAdd, ns):
+ raise HTTPForbidden(
+ (
+ "The name {name!r} conflicts with a registered namespace which "
+ "you do not have permission for."
+ ).format(name=name)
+ )
+
def create_project(
self, name, creator, request, *, creator_is_owner=True, ratelimited=True
):
@@ -543,6 +580,10 @@ def create_project(
),
) from None
+ # The name is otherwise valid, but we need to check if the name is part
+ # of a namespace.
+ self.check_namespaces(request, name)
+
# The project name is valid: create it and add it
project = Project(name=name)
self.db.add(project)
diff --git a/warehouse/packaging/utils.py b/warehouse/packaging/utils.py
index 7397cf45a740..0b58694719f8 100644
--- a/warehouse/packaging/utils.py
+++ b/warehouse/packaging/utils.py
@@ -17,8 +17,10 @@
import packaging_legacy.version
from pyramid_jinja2 import IJinja2Environment
+from sqlalchemy import func
from sqlalchemy.orm import joinedload
+from warehouse.organizations.models import Namespace
from warehouse.packaging.interfaces import ISimpleStorage
from warehouse.packaging.models import File, LifecycleStatus, Project, Release
@@ -46,6 +48,33 @@ def _simple_index(request, serial):
def _simple_detail(project, request):
+ # Get the namespace information for this project.
+ # TODO: The PEP states that if we get multiple matching namespaces, it must
+ # be the one with the most characters, but does that mean that if orgA
+ # owns the NS `foo`, and delegates `foo-bar` to orgB, that orgA's grant
+ # on `foo` does not authorize them to release a package under `foo-bar`?
+ namespace = None
+ if (
+ ns := request.db.query(Namespace)
+ .filter(
+ (
+ (Namespace.normalized_name == project.normalized_name)
+ | func.starts_with(
+ project.normalized_name,
+ func.concat(Namespace.normalized_name, "-"),
+ )
+ )
+ & (Namespace.is_approved == True) # noqa E712
+ )
+ .order_by(func.length(Namespace.normalized_name).desc())
+ .first()
+ ):
+ namespace = {
+ "prefix": ns.normalized_name,
+ "authorized": ns.is_project_authorized(project),
+ "open": ns.is_open,
+ }
+
# Get all of the files for this project.
files = sorted(
request.db.query(File)
@@ -70,6 +99,7 @@ def _simple_detail(project, request):
return {
"meta": {"api-version": API_VERSION, "_last-serial": project.last_serial},
"name": project.normalized_name,
+ "namespace": namespace,
"versions": versions,
"alternate-locations": alternate_repositories,
"files": [
diff --git a/warehouse/routes.py b/warehouse/routes.py
index 6c057befa50e..33a9bdc81c2d 100644
--- a/warehouse/routes.py
+++ b/warehouse/routes.py
@@ -315,6 +315,13 @@ def includeme(config):
traverse="/{organization_name}",
domain=warehouse,
)
+ config.add_route(
+ "manage.organization.namespaces",
+ "/manage/organization/{organization_name}/namespaces/",
+ factory="warehouse.organizations.models:OrganizationFactory",
+ traverse="/{organization_name}",
+ domain=warehouse,
+ )
config.add_route(
"manage.organization.roles",
"/manage/organization/{organization_name}/people/",
diff --git a/warehouse/static/images/circle-nodes.png b/warehouse/static/images/circle-nodes.png
new file mode 100644
index 000000000000..fd1f006ca7d3
Binary files /dev/null and b/warehouse/static/images/circle-nodes.png differ
diff --git a/warehouse/static/images/circle-nodes.svg b/warehouse/static/images/circle-nodes.svg
new file mode 100755
index 000000000000..44e4bb3fc8a0
--- /dev/null
+++ b/warehouse/static/images/circle-nodes.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/warehouse/static/sass/blocks/_namespace-snippet.scss b/warehouse/static/sass/blocks/_namespace-snippet.scss
new file mode 100644
index 000000000000..30fed7743fef
--- /dev/null
+++ b/warehouse/static/sass/blocks/_namespace-snippet.scss
@@ -0,0 +1,33 @@
+/*!
+ * 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 "snippet";
+
+/*
+ A card that contains information about a namespace. Often found in namespace lists.
+ The card can be an "a" or "div" element, but if it contains a link, choosing
+ a top-level "a" element is recommended for accessibility reasons.
+
+
+
+ // Package title
+ // Version // Optional!
+
+ // Description
+
+*/
+
+.namespace-snippet {
+ @include snippet(url("../images/circle-nodes.png"), url("../images/circle-nodes.svg"));
+}
diff --git a/warehouse/static/sass/warehouse.scss b/warehouse/static/sass/warehouse.scss
index 15b521b0763b..8ccf2fb0128d 100644
--- a/warehouse/static/sass/warehouse.scss
+++ b/warehouse/static/sass/warehouse.scss
@@ -101,6 +101,7 @@
@import "blocks/package-description";
@import "blocks/package-header";
@import "blocks/package-snippet";
+@import "blocks/namespace-snippet";
/*rtl:end:ignore*/
@import "blocks/password-strength";
/*rtl:begin:ignore*/
diff --git a/warehouse/templates/includes/manage/manage-organization-menu.html b/warehouse/templates/includes/manage/manage-organization-menu.html
index c3087ce8773e..248d28b5ed7f 100644
--- a/warehouse/templates/includes/manage/manage-organization-menu.html
+++ b/warehouse/templates/includes/manage/manage-organization-menu.html
@@ -34,6 +34,13 @@
{{ organization.teams|length }}
+
+
+
+ {% trans %}Namespaces{% endtrans %}
+ {{ organization.namespaces|length }}
+
+
{% if request.has_permission(Permissions.OrganizationsManage) %}
diff --git a/warehouse/templates/manage/organization/history.html b/warehouse/templates/manage/organization/history.html
index a1d2a4381bf7..3b03834c77f7 100644
--- a/warehouse/templates/manage/organization/history.html
+++ b/warehouse/templates/manage/organization/history.html
@@ -121,6 +121,10 @@ {% trans %}Security history{% endtrans %}
{% trans href=request.route_path('accounts.profile', username=target_user), username=target_user, team_name=event.additional.team_name %} {{ username }} removed from {{ team_name }} team{% endtrans %}
+ {# Display action for namespace events #}
+ {% elif event.tag == EventTag.Organization.NamespaceRequest %}
+ {% trans namespace_name=event.additional.namespace_name %}{{ namespace_name }} namespace requested{% endtrans %}
+
{% else %}
{{ event.tag }}
{% endif %}
@@ -159,6 +163,13 @@ {% trans %}Security history{% endtrans %}
{% trans %}Declined by:{% endtrans %} {{ declined_by }}
+ {# Display acting user for namespace events #}
+ {% elif event.tag == EventTag.Organization.NamespaceRequest %}
+ {% set requested_by = get_user(event.additional.requested_by_user_id).username %}
+
+ {% trans %}Requested by:{% endtrans %} {{ requested_by }}
+
+
{# Display submitting user for role events #}
{% elif event.tag.endswith(":add") %}
{% set submitted_by = get_user(event.additional.submitted_by_user_id).username %}
diff --git a/warehouse/templates/manage/organization/namespaces.html b/warehouse/templates/manage/organization/namespaces.html
new file mode 100644
index 000000000000..99bb7410a3f5
--- /dev/null
+++ b/warehouse/templates/manage/organization/namespaces.html
@@ -0,0 +1,90 @@
+{#
+ # 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.
+ -#}
+ {% extends "manage_organization_base.html" %}
+
+ {% set user = request.user %}
+ {% set title = gettext("Organization namespaces") %}
+
+ {% set active_tab = 'namespaces' %}
+
+ {% block title %}{% trans organization_name=organization.name %}Manage '{{ organization_name }}' namespaces{% endtrans %}{% endblock %}
+
+ {% block main %}
+
+ {% trans %}Namespaces{% endtrans %}
+ {{ organization.namespaces|length }}
+
+
+
+ {% for namespace in organization.namespaces %}
+
+
+
+
+ {{ namespace.name }}
+
+ {% if not namespace.is_approved %}
+ {% trans %}Request Submitted{% endtrans %}
+ {% endif %}
+
+
+ {% trans creation_date=humanize(namespace.created) %}Created {{ creation_date }}{% endtrans %}
+
+
+
+
+
+
+ {% else %}
+
+
+ {% trans %}There are no namespaces in your organization, yet. Organization owners and managers can request new namespaces for the organization.{% endtrans %}
+
+
+ {% endfor %}
+
+
+ {% if request.has_permission(Permissions.OrganizationNamespaceManage) %}
+
+
+ {{ form_error_anchor(request_organization_namespace_form) }}
+
+ {% trans %}Request namespace{% endtrans %}
+
+
+ {% endif %}
+ {% endblock %}
+
\ No newline at end of file