diff --git a/Gulpfile.babel.js b/Gulpfile.babel.js
index 6ae8f835c57b..bab18b3d3f7a 100644
--- a/Gulpfile.babel.js
+++ b/Gulpfile.babel.js
@@ -163,6 +163,7 @@ gulp.task("dist:admin:css", () => {
"warehouse/admin/static/css/ionicons.min.css",
"warehouse/admin/static/css/AdminLTE.min.css",
"warehouse/admin/static/css/skins/skin-purple.min.css",
+ "warehouse/admin/static/css/admin.css",
];
return gulp.src(files)
.pipe(gulpConcat("all.css"))
diff --git a/tests/common/db/organizations.py b/tests/common/db/organizations.py
index 0c5bda236cc3..09ce232fd8cc 100644
--- a/tests/common/db/organizations.py
+++ b/tests/common/db/organizations.py
@@ -41,15 +41,13 @@ class Meta:
link_url = factory.Faker("uri")
description = factory.Faker("sentence")
is_active = True
- is_approved = False
+ is_approved = None
created = factory.Faker(
"date_time_between_dates",
datetime_start=datetime.datetime(2020, 1, 1),
datetime_end=datetime.datetime(2022, 1, 1),
)
- date_approved = factory.Faker(
- "date_time_between_dates", datetime_start=datetime.datetime(2020, 1, 1)
- )
+ date_approved = None
class OrganizationEventFactory(WarehouseFactory):
diff --git a/tests/unit/admin/views/test_organizations.py b/tests/unit/admin/views/test_organizations.py
index a2bd921d8c6c..10f7790ba602 100644
--- a/tests/unit/admin/views/test_organizations.py
+++ b/tests/unit/admin/views/test_organizations.py
@@ -59,11 +59,9 @@ def test_basic_query(self, enable_organizations, db_request):
db_request.GET["q"] = organizations[0].name
result = views.organization_list(db_request)
- assert result == {
- "organizations": [organizations[0]],
- "query": organizations[0].name,
- "terms": [organizations[0].name],
- }
+ assert organizations[0] in result["organizations"]
+ assert result["query"] == organizations[0].name
+ assert result["terms"] == [organizations[0].name]
def test_name_query(self, enable_organizations, db_request):
organizations = sorted(
@@ -73,11 +71,9 @@ def test_name_query(self, enable_organizations, db_request):
db_request.GET["q"] = f"name:{organizations[0].name}"
result = views.organization_list(db_request)
- assert result == {
- "organizations": [organizations[0]],
- "query": f"name:{organizations[0].name}",
- "terms": [f"name:{organizations[0].name}"],
- }
+ assert organizations[0] in result["organizations"]
+ assert result["query"] == f"name:{organizations[0].name}"
+ assert result["terms"] == [f"name:{organizations[0].name}"]
def test_organization_query(self, enable_organizations, db_request):
organizations = sorted(
@@ -87,11 +83,9 @@ def test_organization_query(self, enable_organizations, db_request):
db_request.GET["q"] = f"organization:{organizations[0].display_name}"
result = views.organization_list(db_request)
- assert result == {
- "organizations": [organizations[0]],
- "query": f"organization:{organizations[0].display_name}",
- "terms": [f"organization:{organizations[0].display_name}"],
- }
+ assert organizations[0] in result["organizations"]
+ assert result["query"] == f"organization:{organizations[0].display_name}"
+ assert result["terms"] == [f"organization:{organizations[0].display_name}"]
def test_url_query(self, enable_organizations, db_request):
organizations = sorted(
@@ -101,11 +95,9 @@ def test_url_query(self, enable_organizations, db_request):
db_request.GET["q"] = f"url:{organizations[0].link_url}"
result = views.organization_list(db_request)
- assert result == {
- "organizations": [organizations[0]],
- "query": f"url:{organizations[0].link_url}",
- "terms": [f"url:{organizations[0].link_url}"],
- }
+ assert organizations[0] in result["organizations"]
+ assert result["query"] == f"url:{organizations[0].link_url}"
+ assert result["terms"] == [f"url:{organizations[0].link_url}"]
def test_description_query(self, enable_organizations, db_request):
organizations = sorted(
@@ -115,11 +107,9 @@ def test_description_query(self, enable_organizations, db_request):
db_request.GET["q"] = f"description:'{organizations[0].description}'"
result = views.organization_list(db_request)
- assert result == {
- "organizations": [organizations[0]],
- "query": f"description:'{organizations[0].description}'",
- "terms": [f"description:{organizations[0].description}"],
- }
+ assert organizations[0] in result["organizations"]
+ assert result["query"] == f"description:'{organizations[0].description}'"
+ assert result["terms"] == [f"description:{organizations[0].description}"]
def test_is_approved_query(self, enable_organizations, db_request):
organizations = sorted(
diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py
index c38eecc86eae..fb914b47990e 100644
--- a/tests/unit/manage/test_views.py
+++ b/tests/unit/manage/test_views.py
@@ -58,6 +58,7 @@
from warehouse.utils.project import remove_documentation
from ...common.db.accounts import EmailFactory
+from ...common.db.organizations import OrganizationFactory
from ...common.db.packaging import (
FileFactory,
JournalEntryFactory,
@@ -2313,11 +2314,26 @@ def test_default_response(self, monkeypatch):
)
monkeypatch.setattr(views, "CreateOrganizationForm", create_organization_cls)
+ organization = pretend.stub(name=pretend.stub())
+
+ user_organizations = pretend.call_recorder(
+ lambda *a, **kw: {
+ "organizations_managed": [],
+ "organizations_owned": [organization],
+ "organizations_billing": [],
+ }
+ )
+ monkeypatch.setattr(views, "user_organizations", user_organizations)
+
+ organization_service = pretend.stub(
+ get_organizations_by_user=lambda *a, **kw: [organization]
+ )
+ user_service = pretend.stub()
request = pretend.stub(
user=pretend.stub(id=pretend.stub(), username=pretend.stub()),
find_service=lambda interface, **kw: {
- IOrganizationService: pretend.stub(),
- IUserService: pretend.stub(),
+ IOrganizationService: organization_service,
+ IUserService: user_service,
}[interface],
)
@@ -2325,6 +2341,10 @@ def test_default_response(self, monkeypatch):
assert view.default_response == {
"create_organization_form": create_organization_obj,
+ "organizations": [organization],
+ "organizations_managed": [],
+ "organizations_owned": [organization.name],
+ "organizations_billing": [],
}
def test_manage_organizations(self, monkeypatch):
@@ -2593,6 +2613,27 @@ def test_create_organizations_disable_organizations(self, monkeypatch):
]
+class TestManageOrganizationRoles:
+ def test_get_manage_organization_roles(self, db_request):
+ organization = OrganizationFactory.create(name="foobar")
+ request = pretend.stub(
+ flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)),
+ )
+
+ result = views.manage_organization_roles(organization, request)
+
+ assert result == {"organization": organization}
+
+ def test_get_manage_organization_roles_disable_organizations(self, db_request):
+ organization = OrganizationFactory.create(name="foobar")
+ request = pretend.stub(
+ flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: True)),
+ )
+
+ with pytest.raises(HTTPNotFound):
+ views.manage_organization_roles(organization, request)
+
+
class TestManageProjects:
def test_manage_projects(self, db_request):
older_release = ReleaseFactory(created=datetime.datetime(2015, 1, 1))
diff --git a/tests/unit/organizations/test_models.py b/tests/unit/organizations/test_models.py
new file mode 100644
index 000000000000..bafc61deab06
--- /dev/null
+++ b/tests/unit/organizations/test_models.py
@@ -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 pytest
+
+from warehouse.organizations.models import OrganizationFactory
+
+from ...common.db.organizations import OrganizationFactory as DBOrganizationFactory
+
+
+class TestOrganizationFactory:
+ @pytest.mark.parametrize(("name", "normalized"), [("foo", "foo"), ("Bar", "bar")])
+ def test_traversal_finds(self, db_request, name, normalized):
+ organization = DBOrganizationFactory.create(name=name)
+ root = OrganizationFactory(db_request)
+
+ assert root[normalized] == organization
+
+ def test_traversal_cant_find(self, db_request):
+ organization = DBOrganizationFactory.create()
+ root = OrganizationFactory(db_request)
+
+ with pytest.raises(KeyError):
+ root[organization.name + "invalid"]
diff --git a/tests/unit/organizations/test_services.py b/tests/unit/organizations/test_services.py
index 571469911ee1..02c44a5dbbd8 100644
--- a/tests/unit/organizations/test_services.py
+++ b/tests/unit/organizations/test_services.py
@@ -64,6 +64,54 @@ def test_find_organizationid(self, organization_service):
def test_find_organizationid_nonexistent_org(self, organization_service):
assert organization_service.find_organizationid("a_spoon_in_the_matrix") is None
+ def test_get_organizations(self, organization_service):
+ organization = OrganizationFactory.create(name="org")
+ another_organization = OrganizationFactory.create(name="another_org")
+ orgs = organization_service.get_organizations()
+
+ assert organization in orgs
+ assert another_organization in orgs
+
+ def test_get_organizations_needing_approval(self, organization_service):
+ i_need_it = OrganizationFactory.create()
+ assert i_need_it.is_approved is None
+
+ i_has_it = OrganizationFactory.create()
+ organization_service.approve_organization(i_has_it.id)
+ assert i_has_it.is_approved is True
+
+ orgs_needing_approval = (
+ organization_service.get_organizations_needing_approval()
+ )
+
+ assert i_need_it in orgs_needing_approval
+ assert i_has_it not in orgs_needing_approval
+
+ def test_get_organizations_by_user(self, organization_service, user_service):
+ user_organization = OrganizationFactory.create()
+ user = UserFactory.create()
+ organization_service.add_organization_role(
+ OrganizationRoleType.Owner.value, user.id, user_organization.id
+ )
+
+ another_user_organization = OrganizationFactory.create()
+ another_user = UserFactory.create()
+ organization_service.add_organization_role(
+ OrganizationRoleType.Owner.value,
+ another_user.id,
+ another_user_organization.id,
+ )
+
+ user_orgs = organization_service.get_organizations_by_user(user.id)
+ another_user_orgs = organization_service.get_organizations_by_user(
+ another_user.id
+ )
+
+ assert user_organization in user_orgs
+ assert user_organization not in another_user_orgs
+ assert another_user_organization in another_user_orgs
+ assert another_user_organization not in user_orgs
+
def test_add_organization(self, organization_service):
organization = OrganizationFactory.create()
new_org = organization_service.add_organization(
diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py
index dcccf822e5d8..1baadf16bfb5 100644
--- a/tests/unit/test_routes.py
+++ b/tests/unit/test_routes.py
@@ -240,6 +240,13 @@ def add_policy(name, filename):
pretend.call(
"manage.organizations", "/manage/organizations/", domain=warehouse
),
+ pretend.call(
+ "manage.organization.roles",
+ "/manage/organization/{organization_name}/people/",
+ factory="warehouse.organizations.models:OrganizationFactory",
+ traverse="/{organization_name}",
+ domain=warehouse,
+ ),
pretend.call("manage.projects", "/manage/projects/", domain=warehouse),
pretend.call(
"manage.project.settings",
diff --git a/warehouse/admin/static/css/admin.css b/warehouse/admin/static/css/admin.css
new file mode 100644
index 000000000000..38cec5d3ee10
--- /dev/null
+++ b/warehouse/admin/static/css/admin.css
@@ -0,0 +1,22 @@
+/*!
+ * 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.
+ */
+
+@charset "utf-8";
+
+/* Admin styles for the Warehouse project (PyPI) */
+
+.table td,
+.table th {
+ max-width: 30em;
+}
diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot
index 697009e306b4..9714a7459a80 100644
--- a/warehouse/locale/messages.pot
+++ b/warehouse/locale/messages.pot
@@ -116,7 +116,7 @@ msgstr ""
msgid "Successful WebAuthn assertion"
msgstr ""
-#: warehouse/accounts/views.py:441 warehouse/manage/views.py:817
+#: warehouse/accounts/views.py:441 warehouse/manage/views.py:822
msgid "Recovery code accepted. The supplied code cannot be used again."
msgstr ""
@@ -261,51 +261,51 @@ msgid ""
"description with 400 characters or less."
msgstr ""
-#: warehouse/manage/views.py:248
+#: warehouse/manage/views.py:253
msgid "Email ${email_address} added - check your email for a verification link"
msgstr ""
-#: warehouse/manage/views.py:765
+#: warehouse/manage/views.py:770
msgid "Recovery codes already generated"
msgstr ""
-#: warehouse/manage/views.py:766
+#: warehouse/manage/views.py:771
msgid "Generating new recovery codes will invalidate your existing codes."
msgstr ""
-#: warehouse/manage/views.py:1299
+#: warehouse/manage/views.py:1389
msgid ""
"There have been too many attempted OpenID Connect registrations. Try "
"again later."
msgstr ""
-#: warehouse/manage/views.py:1980
+#: warehouse/manage/views.py:2070
msgid "User '${username}' already has ${role_name} role for project"
msgstr ""
-#: warehouse/manage/views.py:1991
+#: warehouse/manage/views.py:2081
msgid ""
"User '${username}' does not have a verified primary email address and "
"cannot be added as a ${role_name} for project"
msgstr ""
-#: warehouse/manage/views.py:2004
+#: warehouse/manage/views.py:2094
msgid "User '${username}' already has an active invite. Please try again later."
msgstr ""
-#: warehouse/manage/views.py:2062
+#: warehouse/manage/views.py:2152
msgid "Invitation sent to '${username}'"
msgstr ""
-#: warehouse/manage/views.py:2109
+#: warehouse/manage/views.py:2199
msgid "Could not find role invitation."
msgstr ""
-#: warehouse/manage/views.py:2120
+#: warehouse/manage/views.py:2210
msgid "Invitation already expired."
msgstr ""
-#: warehouse/manage/views.py:2144
+#: warehouse/manage/views.py:2234
msgid "Invitation revoked from '${username}'."
msgstr ""
@@ -623,8 +623,8 @@ msgstr ""
#: warehouse/templates/includes/session-notifications.html:20
#: warehouse/templates/manage/account.html:716
#: warehouse/templates/manage/documentation.html:27
-#: warehouse/templates/manage/manage_base.html:278
-#: warehouse/templates/manage/manage_base.html:330
+#: warehouse/templates/manage/manage_base.html:294
+#: warehouse/templates/manage/manage_base.html:346
#: warehouse/templates/manage/release.html:163
#: warehouse/templates/manage/settings.html:119
msgid "Warning"
@@ -906,7 +906,7 @@ msgstr ""
#: warehouse/templates/accounts/login.html:69
#: warehouse/templates/accounts/register.html:110
#: warehouse/templates/accounts/reset-password.html:38
-#: warehouse/templates/manage/manage_base.html:339
+#: warehouse/templates/manage/manage_base.html:355
#: warehouse/templates/re-auth.html:49
msgid "Password"
msgstr ""
@@ -932,11 +932,11 @@ msgstr ""
#: warehouse/templates/manage/account/recovery_codes-burn.html:70
#: warehouse/templates/manage/account/totp-provision.html:69
#: warehouse/templates/manage/account/webauthn-provision.html:44
-#: warehouse/templates/manage/organizations.html:34
-#: warehouse/templates/manage/organizations.html:57
-#: warehouse/templates/manage/organizations.html:79
-#: warehouse/templates/manage/organizations.html:97
-#: warehouse/templates/manage/organizations.html:116
+#: warehouse/templates/manage/organizations.html:89
+#: warehouse/templates/manage/organizations.html:112
+#: warehouse/templates/manage/organizations.html:134
+#: warehouse/templates/manage/organizations.html:152
+#: warehouse/templates/manage/organizations.html:171
#: warehouse/templates/manage/publishing.html:85
#: warehouse/templates/manage/publishing.html:97
#: warehouse/templates/manage/publishing.html:109
@@ -954,7 +954,7 @@ msgid "Your password"
msgstr ""
#: warehouse/templates/accounts/login.html:92
-#: warehouse/templates/manage/manage_base.html:342
+#: warehouse/templates/manage/manage_base.html:358
#: warehouse/templates/re-auth.html:72
msgid "Show password"
msgstr ""
@@ -1088,7 +1088,7 @@ msgstr ""
#: warehouse/templates/accounts/profile.html:73
#: warehouse/templates/manage/projects.html:34
-#: warehouse/templates/manage/projects.html:99
+#: warehouse/templates/manage/projects.html:98
#, python-format
msgid "Last released %(release_date)s"
msgstr ""
@@ -1398,9 +1398,8 @@ msgid ""
msgstr ""
#: warehouse/templates/email/new-organization-requested/body.html:19
-msgid ""
-"You will receive another email when the PyPI organization has been "
-"approved."
+#: warehouse/templates/manage/organizations.html:30
+msgid "You will receive an email when the organization has been approved"
msgstr ""
#: warehouse/templates/email/oidc-provider-added/body.html:19
@@ -1691,7 +1690,7 @@ msgstr ""
#: warehouse/templates/includes/current-user-indicator.html:37
#: warehouse/templates/manage/manage_base.html:204
-#: warehouse/templates/manage/manage_base.html:224
+#: warehouse/templates/manage/manage_base.html:232
#: warehouse/templates/manage/projects.html:18
msgid "Your projects"
msgstr ""
@@ -1699,8 +1698,8 @@ msgstr ""
#: warehouse/templates/includes/current-user-indicator.html:43
#: warehouse/templates/manage/account.html:17
#: warehouse/templates/manage/account/two-factor.html:17
-#: warehouse/templates/manage/manage_base.html:210
-#: warehouse/templates/manage/manage_base.html:230
+#: warehouse/templates/manage/manage_base.html:218
+#: warehouse/templates/manage/manage_base.html:246
msgid "Account settings"
msgstr ""
@@ -1730,8 +1729,8 @@ msgstr ""
#: warehouse/templates/manage/account.html:199
#: warehouse/templates/manage/account.html:201
#: warehouse/templates/manage/account.html:211
-#: warehouse/templates/manage/manage_base.html:266
-#: warehouse/templates/manage/manage_base.html:268
+#: warehouse/templates/manage/manage_base.html:282
+#: warehouse/templates/manage/manage_base.html:284
#: warehouse/templates/manage/release.html:118
#: warehouse/templates/manage/releases.html:172
#: warehouse/templates/manage/roles.html:38
@@ -1874,6 +1873,16 @@ msgstr ""
msgid "Email"
msgstr ""
+#: warehouse/templates/includes/manage/manage-organization-menu.html:14
+#, python-format
+msgid "Navigation for managing %(organization)s"
+msgstr ""
+
+#: warehouse/templates/includes/manage/manage-organization-menu.html:19
+#: warehouse/templates/manage/organization/roles.html:35
+msgid "People"
+msgstr ""
+
#: warehouse/templates/includes/manage/manage-project-menu.html:14
#, python-format
msgid "Navigation for managing %(project)s"
@@ -2764,8 +2773,12 @@ msgid "from %(ip_address)s"
msgstr ""
#: warehouse/templates/manage/manage_base.html:16
-#: warehouse/templates/manage/projects.html:143
-#: warehouse/templates/manage/projects.html:148
+#: warehouse/templates/manage/organizations.html:49
+#: warehouse/templates/manage/organizations.html:54
+#: warehouse/templates/manage/organizations.html:59
+#: warehouse/templates/manage/organizations.html:64
+#: warehouse/templates/manage/projects.html:142
+#: warehouse/templates/manage/projects.html:147
#: warehouse/templates/manage/releases.html:88
msgid "Manage"
msgstr ""
@@ -2944,34 +2957,40 @@ msgid "Your account"
msgstr ""
#: warehouse/templates/manage/manage_base.html:199
-#: warehouse/templates/manage/manage_base.html:219
+#: warehouse/templates/manage/manage_base.html:227
msgid "Account navigation"
msgstr ""
-#: warehouse/templates/manage/manage_base.html:279
-#: warehouse/templates/manage/manage_base.html:331
+#: warehouse/templates/manage/manage_base.html:211
+#: warehouse/templates/manage/manage_base.html:239
+#: warehouse/templates/manage/organizations.html:18
+msgid "Your organizations"
+msgstr ""
+
+#: warehouse/templates/manage/manage_base.html:295
+#: warehouse/templates/manage/manage_base.html:347
msgid "This action cannot be undone!"
msgstr ""
-#: warehouse/templates/manage/manage_base.html:287
+#: warehouse/templates/manage/manage_base.html:303
msgid "Confirm your username to continue."
msgstr ""
-#: warehouse/templates/manage/manage_base.html:289
+#: warehouse/templates/manage/manage_base.html:305
#, python-format
msgid "Confirm the %(item)s to continue."
msgstr ""
-#: warehouse/templates/manage/manage_base.html:297
-#: warehouse/templates/manage/manage_base.html:349
+#: warehouse/templates/manage/manage_base.html:313
+#: warehouse/templates/manage/manage_base.html:365
msgid "Cancel"
msgstr ""
-#: warehouse/templates/manage/manage_base.html:320
+#: warehouse/templates/manage/manage_base.html:336
msgid "close"
msgstr ""
-#: warehouse/templates/manage/manage_base.html:336
+#: warehouse/templates/manage/manage_base.html:352
msgid "Enter your password to continue."
msgstr ""
@@ -2990,67 +3009,117 @@ msgstr ""
msgid "Back to projects"
msgstr ""
-#: warehouse/templates/manage/organizations.html:18
-msgid "Your organizations"
+#: warehouse/templates/manage/organization/manage_organization_base.html:32
+#: warehouse/templates/manage/organizations.html:21
+#, python-format
+msgid "Your organizations (%(organization_count)s)"
msgstr ""
-#: warehouse/templates/manage/organizations.html:25
-msgid "Create new organization"
+#: warehouse/templates/manage/organizations.html:30
+msgid "Request Submitted"
+msgstr ""
+
+#: warehouse/templates/manage/organizations.html:32
+#: warehouse/templates/manage/organizations.html:53
+msgid "This organization is not active"
msgstr ""
#: warehouse/templates/manage/organizations.html:32
+msgid "Inactive"
+msgstr ""
+
+#: warehouse/templates/manage/organizations.html:34
+msgid "Manager"
+msgstr ""
+
+#: warehouse/templates/manage/organizations.html:36
+#: warehouse/templates/manage/publishing.html:83
+#: warehouse/templates/manage/roles.html:43
+#: warehouse/templates/manage/roles.html:77
+#: warehouse/templates/manage/roles.html:88
+msgid "Owner"
+msgstr ""
+
+#: warehouse/templates/manage/organizations.html:38
+msgid "Billing Manager"
+msgstr ""
+
+#: warehouse/templates/manage/organizations.html:42
+#: warehouse/templates/manage/projects.html:41
+#: warehouse/templates/manage/projects.html:105
+#, python-format
+msgid "Created %(creation_date)s"
+msgstr ""
+
+#: warehouse/templates/manage/organizations.html:48
+msgid "You are not a manager or an owner of this organization"
+msgstr ""
+
+#: warehouse/templates/manage/organizations.html:63
+msgid "Manage this organization"
+msgstr ""
+
+#: warehouse/templates/manage/organizations.html:72
+msgid "You have not joined any organizations on PyPI, yet."
+msgstr ""
+
+#: warehouse/templates/manage/organizations.html:81
+msgid "Create new organization"
+msgstr ""
+
+#: warehouse/templates/manage/organizations.html:87
msgid "Organization account name"
msgstr ""
-#: warehouse/templates/manage/organizations.html:37
+#: warehouse/templates/manage/organizations.html:92
msgid "Select an organization account name"
msgstr ""
-#: warehouse/templates/manage/organizations.html:48
+#: warehouse/templates/manage/organizations.html:103
msgid "This account name will be used in URLs on PyPI."
msgstr ""
-#: warehouse/templates/manage/organizations.html:49
-#: warehouse/templates/manage/organizations.html:71
-#: warehouse/templates/manage/organizations.html:89
+#: warehouse/templates/manage/organizations.html:104
+#: warehouse/templates/manage/organizations.html:126
+#: warehouse/templates/manage/organizations.html:144
msgid "For example"
msgstr ""
-#: warehouse/templates/manage/organizations.html:55
+#: warehouse/templates/manage/organizations.html:110
msgid "Organization name"
msgstr ""
-#: warehouse/templates/manage/organizations.html:60
+#: warehouse/templates/manage/organizations.html:115
msgid "Name of your business, product, or project"
msgstr ""
-#: warehouse/templates/manage/organizations.html:77
+#: warehouse/templates/manage/organizations.html:132
msgid "ď¸Organization URL"
msgstr ""
-#: warehouse/templates/manage/organizations.html:83
+#: warehouse/templates/manage/organizations.html:138
msgid "URL for your business, product, or project"
msgstr ""
-#: warehouse/templates/manage/organizations.html:95
+#: warehouse/templates/manage/organizations.html:150
msgid "Organization description"
msgstr ""
-#: warehouse/templates/manage/organizations.html:100
+#: warehouse/templates/manage/organizations.html:155
msgid "Description of your business, product, or project"
msgstr ""
-#: warehouse/templates/manage/organizations.html:114
+#: warehouse/templates/manage/organizations.html:169
msgid "ď¸Organization type"
msgstr ""
-#: warehouse/templates/manage/organizations.html:126
+#: warehouse/templates/manage/organizations.html:181
msgid ""
"Companies can create organization accounts as a paid service while "
"community projects are granted complimentary access."
msgstr ""
-#: warehouse/templates/manage/organizations.html:132
+#: warehouse/templates/manage/organizations.html:187
msgid "Create"
msgstr ""
@@ -3059,69 +3128,63 @@ msgstr ""
msgid "Pending invitations (%(project_count)s)"
msgstr ""
-#: warehouse/templates/manage/projects.html:41
-#: warehouse/templates/manage/projects.html:106
-#, python-format
-msgid "Created %(creation_date)s"
+#: warehouse/templates/manage/projects.html:71
+#: warehouse/templates/manage/projects.html:134
+msgid "This project requires 2FA to be enabled to manage"
msgstr ""
#: warehouse/templates/manage/projects.html:72
#: warehouse/templates/manage/projects.html:135
-msgid "This project requires 2FA to be enabled to manage"
-msgstr ""
-
-#: warehouse/templates/manage/projects.html:73
-#: warehouse/templates/manage/projects.html:136
msgid "2FA Required"
msgstr ""
-#: warehouse/templates/manage/projects.html:79
-#: warehouse/templates/manage/projects.html:89
+#: warehouse/templates/manage/projects.html:78
+#: warehouse/templates/manage/projects.html:88
msgid "This is a critical project for the Python ecosystem"
msgstr ""
-#: warehouse/templates/manage/projects.html:80
-#: warehouse/templates/manage/projects.html:90
+#: warehouse/templates/manage/projects.html:79
+#: warehouse/templates/manage/projects.html:89
msgid "Critical Project"
msgstr ""
-#: warehouse/templates/manage/projects.html:82
-#: warehouse/templates/manage/projects.html:123
+#: warehouse/templates/manage/projects.html:81
+#: warehouse/templates/manage/projects.html:122
msgid "PyPI requires 2FA to be enabled to manage this project"
msgstr ""
-#: warehouse/templates/manage/projects.html:83
-#: warehouse/templates/manage/projects.html:124
+#: warehouse/templates/manage/projects.html:82
+#: warehouse/templates/manage/projects.html:123
msgid "2FA Mandated"
msgstr ""
-#: warehouse/templates/manage/projects.html:94
+#: warehouse/templates/manage/projects.html:93
msgid "Sole owner"
msgstr ""
-#: warehouse/templates/manage/projects.html:142
+#: warehouse/templates/manage/projects.html:141
msgid "Manage this project"
msgstr ""
-#: warehouse/templates/manage/projects.html:147
+#: warehouse/templates/manage/projects.html:146
msgid "You are not an owner of this project"
msgstr ""
-#: warehouse/templates/manage/projects.html:155
+#: warehouse/templates/manage/projects.html:154
msgid "View this project's public page"
msgstr ""
-#: warehouse/templates/manage/projects.html:156
-#: warehouse/templates/manage/projects.html:159
+#: warehouse/templates/manage/projects.html:155
+#: warehouse/templates/manage/projects.html:158
#: warehouse/templates/manage/releases.html:94
msgid "View"
msgstr ""
-#: warehouse/templates/manage/projects.html:158
+#: warehouse/templates/manage/projects.html:157
msgid "This project has no releases"
msgstr ""
-#: warehouse/templates/manage/projects.html:168
+#: warehouse/templates/manage/projects.html:166
#, python-format
msgid ""
"You have not uploaded any projects to PyPI, yet. To learn how to get "
@@ -3159,13 +3222,6 @@ msgid ""
"href=\"%(href)s\">here."
msgstr ""
-#: warehouse/templates/manage/publishing.html:83
-#: warehouse/templates/manage/roles.html:43
-#: warehouse/templates/manage/roles.html:77
-#: warehouse/templates/manage/roles.html:88
-msgid "Owner"
-msgstr ""
-
#: warehouse/templates/manage/publishing.html:88
msgid "owner"
msgstr ""
@@ -3553,16 +3609,20 @@ msgstr ""
msgid "Manage '%(project_name)s' collaborators"
msgstr ""
+#: warehouse/templates/manage/organization/roles.html:22
#: warehouse/templates/manage/roles.html:22
msgid "2FA enabled"
msgstr ""
+#: warehouse/templates/manage/organization/roles.html:23
+#: warehouse/templates/manage/organization/roles.html:28
#: warehouse/templates/manage/roles.html:23
#: warehouse/templates/manage/roles.html:28
#: warehouse/templates/manage/roles.html:53
msgid "2FA"
msgstr ""
+#: warehouse/templates/manage/organization/roles.html:27
#: warehouse/templates/manage/roles.html:27
msgid "2FA disabled"
msgstr ""
@@ -3571,7 +3631,7 @@ msgstr ""
#, python-format
msgid ""
"Use this page to control which PyPI users can help you to manage "
-"%(project_name)s"
+"%(project_name)s."
msgstr ""
#: warehouse/templates/manage/roles.html:39
@@ -4114,6 +4174,27 @@ msgid ""
" not work with PyPI."
msgstr ""
+#: warehouse/templates/manage/organization/manage_organization_base.html:21
+#, python-format
+msgid "Manage '%(organization_name)s'"
+msgstr ""
+
+#: warehouse/templates/manage/organization/manage_organization_base.html:41
+msgid "Back to organizations"
+msgstr ""
+
+#: warehouse/templates/manage/organization/roles.html:18
+#, python-format
+msgid "Manage people in '%(organization_name)s'"
+msgstr ""
+
+#: warehouse/templates/manage/organization/roles.html:36
+#, python-format
+msgid ""
+"Use this page to control which PyPI users can help you to manage "
+"%(organization_name)s."
+msgstr ""
+
#: warehouse/templates/packaging/detail.html:106
msgid "view hashes"
msgstr ""
diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py
index df878e7ae075..3c487360a130 100644
--- a/warehouse/manage/views.py
+++ b/warehouse/manage/views.py
@@ -87,6 +87,11 @@
from warehouse.oidc.interfaces import TooManyOIDCRegistrations
from warehouse.oidc.models import GitHubProvider, OIDCProvider
from warehouse.organizations.interfaces import IOrganizationService
+from warehouse.organizations.models import (
+ Organization,
+ OrganizationRole,
+ OrganizationRoleType,
+)
from warehouse.packaging.models import (
File,
JournalEntry,
@@ -972,6 +977,57 @@ def delete_macaroon(self):
return HTTPSeeOther(redirect_to)
+def user_organizations(request):
+ """Return all the organizations for which the user has a privileged role."""
+ organizations_managed = (
+ request.db.query(Organization.id)
+ .join(OrganizationRole.organization)
+ .filter(
+ OrganizationRole.role_name == OrganizationRoleType.Manager,
+ OrganizationRole.user == request.user,
+ )
+ .subquery()
+ )
+ organizations_owned = (
+ request.db.query(Organization.id)
+ .join(OrganizationRole.organization)
+ .filter(
+ OrganizationRole.role_name == OrganizationRoleType.Owner,
+ OrganizationRole.user == request.user,
+ )
+ .subquery()
+ )
+ organizations_billing = (
+ request.db.query(Organization.id)
+ .join(OrganizationRole.organization)
+ .filter(
+ OrganizationRole.role_name == OrganizationRoleType.BillingManager,
+ OrganizationRole.user == request.user,
+ )
+ .subquery()
+ )
+ return {
+ "organizations_owned": (
+ request.db.query(Organization)
+ .join(organizations_owned, Organization.id == organizations_owned.c.id)
+ .order_by(Organization.name)
+ .all()
+ ),
+ "organizations_managed": (
+ request.db.query(Organization)
+ .join(organizations_managed, Organization.id == organizations_managed.c.id)
+ .order_by(Organization.name)
+ .all()
+ ),
+ "organizations_billing": (
+ request.db.query(Organization)
+ .join(organizations_billing, Organization.id == organizations_billing.c.id)
+ .order_by(Organization.name)
+ .all()
+ ),
+ }
+
+
@view_defaults(
route_name="manage.organizations",
renderer="manage/organizations.html",
@@ -991,7 +1047,24 @@ def __init__(self, request):
@property
def default_response(self):
+ all_user_organizations = user_organizations(self.request)
return {
+ "organizations": self.organization_service.get_organizations_by_user(
+ self.request.user.id
+ ),
+ **all_user_organizations,
+ "organizations_managed": list(
+ organization.name
+ for organization in all_user_organizations["organizations_managed"]
+ ),
+ "organizations_owned": list(
+ organization.name
+ for organization in all_user_organizations["organizations_owned"]
+ ),
+ "organizations_billing": list(
+ organization.name
+ for organization in all_user_organizations["organizations_billing"]
+ ),
"create_organization_form": CreateOrganizationForm(
organization_service=self.organization_service,
),
@@ -1077,6 +1150,23 @@ def create_organization(self):
return self.default_response
+@view_config(
+ route_name="manage.organization.roles",
+ context=Organization,
+ renderer="manage/organization/roles.html",
+ uses_session=True,
+ require_methods=False,
+ # permission="manage:organization",
+ has_translations=True,
+ require_reauth=True,
+)
+def manage_organization_roles(organization, request):
+ if request.flags.enabled(AdminFlagValue.DISABLE_ORGANIZATIONS):
+ raise HTTPNotFound
+
+ return {"organization": organization}
+
+
@view_config(
route_name="manage.projects",
renderer="manage/projects.html",
diff --git a/warehouse/organizations/interfaces.py b/warehouse/organizations/interfaces.py
index 02894dc5e01e..f54a04824749 100644
--- a/warehouse/organizations/interfaces.py
+++ b/warehouse/organizations/interfaces.py
@@ -32,6 +32,22 @@ def find_organizationid(name):
is no organization with the given name.
"""
+ def get_organizations():
+ """
+ Return a list of all organization objects, or None if there are none.
+ """
+
+ def get_organizations_needing_approval():
+ """
+ Return a list of all organization objects in need of approval or None
+ if there are currently no organization requests.
+ """
+
+ def get_organizations_by_user(user_id):
+ """
+ Return a list of all organization objects associated with a given user id.
+ """
+
def add_organization(name, display_name, orgtype, link_url, description):
"""
Accepts a organization object, and attempts to create an organization with those
diff --git a/warehouse/organizations/models.py b/warehouse/organizations/models.py
index 38b8bdb860f0..e8070a836168 100644
--- a/warehouse/organizations/models.py
+++ b/warehouse/organizations/models.py
@@ -26,8 +26,7 @@
orm,
sql,
)
-
-# from sqlalchemy.orm.exc import NoResultFound
+from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy_utils.types.url import URLType
from warehouse import db
@@ -110,23 +109,22 @@ class OrganizationType(enum.Enum):
Company = "Company"
-# TODO: For future use
-# class OrganizationFactory:
-# def __init__(self, request):
-# self.request = request
-#
-# def __getitem__(self, organization):
-# try:
-# return (
-# self.request.db.query(Organization)
-# .filter(
-# Organization.normalized_name
-# == func.normalize_pep426_name(organization)
-# )
-# .one()
-# )
-# except NoResultFound:
-# raise KeyError from None
+class OrganizationFactory:
+ def __init__(self, request):
+ self.request = request
+
+ def __getitem__(self, organization):
+ try:
+ return (
+ self.request.db.query(Organization)
+ .filter(
+ Organization.normalized_name
+ == func.normalize_pep426_name(organization)
+ )
+ .one()
+ )
+ except NoResultFound:
+ raise KeyError from None
# TODO: Determine if this should also utilize SitemapMixin and TwoFactorRequireable
diff --git a/warehouse/organizations/services.py b/warehouse/organizations/services.py
index 0558f6aa1413..6cbafa4f6eca 100644
--- a/warehouse/organizations/services.py
+++ b/warehouse/organizations/services.py
@@ -64,6 +64,36 @@ def find_organizationid(self, name):
return organization.id
+ def get_organizations(self):
+ """
+ Return a list of all organization objects, or None if there are none.
+ """
+ return self.db.query(Organization).order_by(Organization.name).all()
+
+ def get_organizations_needing_approval(self):
+ """
+ Return a list of all organization objects in need of approval or None
+ if there are currently no organization requests.
+ """
+ return (
+ self.db.query(Organization)
+ .filter(Organization.is_approved == None) # noqa
+ .order_by(Organization.name)
+ .all()
+ )
+
+ def get_organizations_by_user(self, user_id):
+ """
+ Return a list of all organization objects associated with a given user id.
+ """
+ return (
+ self.db.query(Organization)
+ .join(OrganizationRole, OrganizationRole.organization_id == Organization.id)
+ .filter(OrganizationRole.user_id == user_id)
+ .order_by(Organization.name)
+ .all()
+ )
+
def add_organization(self, name, display_name, orgtype, link_url, description):
"""
Accepts a organization object, and attempts to create an organization with those
diff --git a/warehouse/routes.py b/warehouse/routes.py
index c3e28a28f529..5382660949db 100644
--- a/warehouse/routes.py
+++ b/warehouse/routes.py
@@ -222,6 +222,13 @@ def includeme(config):
)
config.add_route("manage.account.token", "/manage/account/token/", domain=warehouse)
config.add_route("manage.organizations", "/manage/organizations/", domain=warehouse)
+ config.add_route(
+ "manage.organization.roles",
+ "/manage/organization/{organization_name}/people/",
+ factory="warehouse.organizations.models:OrganizationFactory",
+ traverse="/{organization_name}",
+ domain=warehouse,
+ )
config.add_route("manage.projects", "/manage/projects/", domain=warehouse)
config.add_route(
"manage.project.settings",
diff --git a/warehouse/static/images/users.png b/warehouse/static/images/users.png
new file mode 100644
index 000000000000..a25395b01b60
Binary files /dev/null and b/warehouse/static/images/users.png differ
diff --git a/warehouse/static/images/users.svg b/warehouse/static/images/users.svg
new file mode 100644
index 000000000000..b591f93b5633
--- /dev/null
+++ b/warehouse/static/images/users.svg
@@ -0,0 +1,6 @@
+
diff --git a/warehouse/static/sass/blocks/_organization-snippet.scss b/warehouse/static/sass/blocks/_organization-snippet.scss
new file mode 100644
index 000000000000..129a81defec4
--- /dev/null
+++ b/warehouse/static/sass/blocks/_organization-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 organization. Often found in organization 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.
+
+
+ // Description
+ // Package title
+ // Version // Optional!
+
+
{% trans organization_name=organization_name %}Your request for a new PyPI organization named "{{ organization_name }}" has been submitted.{% endtrans %}
-{% trans %}You will receive another email when the PyPI organization has been approved.{% endtrans %}
+{% trans %}You will receive an email when the organization has been approved{% endtrans %}.
{% endblock %} diff --git a/warehouse/templates/email/new-organization-requested/body.txt b/warehouse/templates/email/new-organization-requested/body.txt index 73ac8c2e6b29..dcab1a3454d1 100644 --- a/warehouse/templates/email/new-organization-requested/body.txt +++ b/warehouse/templates/email/new-organization-requested/body.txt @@ -16,5 +16,5 @@ {% block content %} {% trans organization_name=organization_name %}Your request for a new PyPI organization named "{{ organization_name }}" has been submitted.{% endtrans %} -{% trans %}You will receive another email when the PyPI organization has been approved.{% endtrans %} +{% trans %}You will receive an email when the organization has been approved{% endtrans %}. {% endblock %} diff --git a/warehouse/templates/includes/manage/manage-organization-menu.html b/warehouse/templates/includes/manage/manage-organization-menu.html new file mode 100644 index 000000000000..5d2dc57352f1 --- /dev/null +++ b/warehouse/templates/includes/manage/manage-organization-menu.html @@ -0,0 +1,23 @@ +{# + # 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. +-#} + diff --git a/warehouse/templates/manage/manage_base.html b/warehouse/templates/manage/manage_base.html index 404868fbe043..ec52afad62a0 100644 --- a/warehouse/templates/manage/manage_base.html +++ b/warehouse/templates/manage/manage_base.html @@ -204,6 +204,14 @@{% trans organization_name=organization.name %}Use this page to control which PyPI users can help you to manage {{ organization_name }}.{% endtrans %}
+{% endblock %} diff --git a/warehouse/templates/manage/organizations.html b/warehouse/templates/manage/organizations.html index 1dacd0227bca..419e088e7362 100644 --- a/warehouse/templates/manage/organizations.html +++ b/warehouse/templates/manage/organizations.html @@ -18,12 +18,67 @@ {% block title %}{% trans %}Your organizations{% endtrans %}{% endblock %} {% block main %} -{% trans %}You have not joined any organizations on PyPI, yet.{% endtrans %}
+