diff --git a/tests/common/db/organizations.py b/tests/common/db/organizations.py new file mode 100644 index 000000000000..f0d8b78e67d6 --- /dev/null +++ b/tests/common/db/organizations.py @@ -0,0 +1,97 @@ +# 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 datetime + +import factory +import faker + +from warehouse.organizations.models import ( + Organization, + OrganizationInvitation, + OrganizationNameCatalog, + OrganizationProject, + OrganizationRole, +) + +from .accounts import UserFactory +from .base import WarehouseFactory +from .packaging import ProjectFactory + +fake = faker.Faker() + + +class OrganizationFactory(WarehouseFactory): + class Meta: + model = Organization + + id = factory.Faker("uuid4", cast_to=None) + name = factory.Faker("word") + normalized_name = factory.Faker("word") + display_name = factory.Faker("word") + orgtype = "Community" + link_url = factory.Faker("uri") + description = factory.Faker("sentence") + is_active = True + is_approved = False + 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) + ) + + +class OrganizationEventFactory(WarehouseFactory): + class Meta: + model = Organization.Event + + source = factory.SubFactory(OrganizationFactory) + + +class OrganizationNameCatalogFactory(WarehouseFactory): + class Meta: + model = OrganizationNameCatalog + + name = factory.Faker("orgname") + organization_id = factory.Faker("uuid4", cast_to=None) + + +class OrganizationRoleFactory(WarehouseFactory): + class Meta: + model = OrganizationRole + + role_name = "Owner" + user = factory.SubFactory(UserFactory) + organization = factory.SubFactory(OrganizationFactory) + + +class OrganizationInvitationFactory(WarehouseFactory): + class Meta: + model = OrganizationInvitation + + invite_status = "pending" + token = "test_token" + user = factory.SubFactory(UserFactory) + organization = factory.SubFactory(OrganizationFactory) + + +class OrganizationProjectFactory(WarehouseFactory): + class Meta: + model = OrganizationProject + + id = factory.Faker("uuid4", cast_to=None) + is_active = True + organization = factory.SubFactory(OrganizationFactory) + project = factory.SubFactory(ProjectFactory) diff --git a/tests/conftest.py b/tests/conftest.py index d2c0c97d0614..c0e7a6837d79 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,7 @@ from warehouse.email.interfaces import IEmailSender from warehouse.macaroons import services as macaroon_services from warehouse.metrics import IMetricsService +from warehouse.organizations import services as organization_services from .common.db import Session @@ -285,6 +286,13 @@ def macaroon_service(db_session): return macaroon_services.DatabaseMacaroonService(db_session) +@pytest.fixture +def organization_service(db_session, remote_addr): + return organization_services.DatabaseOrganizationService( + db_session, remote_addr=remote_addr + ) + + @pytest.fixture def token_service(app_config): return account_services.TokenService(secret="secret", salt="salt", max_age=21600) diff --git a/tests/functional/manage/test_views.py b/tests/functional/manage/test_views.py index dec2e80d2ba1..4c5da434d14e 100644 --- a/tests/functional/manage/test_views.py +++ b/tests/functional/manage/test_views.py @@ -15,7 +15,10 @@ from webob.multidict import MultiDict from warehouse.accounts.interfaces import IPasswordBreachedService, IUserService +from warehouse.admin.flags import AdminFlagValue from warehouse.manage import views +from warehouse.organizations.interfaces import IOrganizationService +from warehouse.organizations.models import OrganizationType from ...common.db.accounts import EmailFactory, UserFactory @@ -23,18 +26,80 @@ class TestManageAccount: def test_save_account(self, pyramid_services, user_service, db_request): breach_service = pretend.stub() + organization_service = pretend.stub() pyramid_services.register_service(user_service, IUserService, None) pyramid_services.register_service( breach_service, IPasswordBreachedService, None ) + pyramid_services.register_service( + organization_service, IOrganizationService, None + ) user = UserFactory.create(name="old name") EmailFactory.create(primary=True, verified=True, public=True, user=user) db_request.user = user db_request.method = "POST" db_request.path = "/manage/accounts/" db_request.POST = MultiDict({"name": "new name", "public_email": ""}) - views.ManageAccountViews(db_request).save_account() + views.ManageAccountViews(db_request).save_account() user = user_service.get_user(user.id) + assert user.name == "new name" assert user.public_email is None + + +class TestManageOrganizations: + def test_create_organization( + self, + pyramid_services, + user_service, + organization_service, + db_request, + monkeypatch, + ): + pyramid_services.register_service(user_service, IUserService, None) + pyramid_services.register_service( + organization_service, IOrganizationService, None + ) + user = UserFactory.create(name="old name") + EmailFactory.create(primary=True, verified=True, public=True, user=user) + db_request.user = user + db_request.method = "POST" + db_request.path = "/manage/organizations/" + db_request.POST = MultiDict( + { + "name": "psf", + "display_name": "Python Software Foundation", + "orgtype": "Community", + "link_url": "https://www.python.org/psf/", + "description": ( + "To promote, protect, and advance the Python programming " + "language, and to support and facilitate the growth of a " + "diverse and international community of Python programmers" + ), + } + ) + monkeypatch.setattr( + db_request, + "flags", + pretend.stub(enabled=pretend.call_recorder(lambda *a: False)), + ) + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr( + views, "send_admin_new_organization_requested_email", send_email + ) + monkeypatch.setattr(views, "send_new_organization_requested_email", send_email) + + views.ManageOrganizationsViews(db_request).create_organization() + organization = organization_service.get_organization_by_name( + db_request.POST["name"] + ) + + assert db_request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISABLE_ORGANIZATIONS), + ] + assert organization.name == db_request.POST["name"] + assert organization.display_name == db_request.POST["display_name"] + assert organization.orgtype == OrganizationType[db_request.POST["orgtype"]] + assert organization.link_url == db_request.POST["link_url"] + assert organization.description == db_request.POST["description"] diff --git a/tests/unit/accounts/test_services.py b/tests/unit/accounts/test_services.py index 42ccebb7e4d0..ee68dc42958b 100644 --- a/tests/unit/accounts/test_services.py +++ b/tests/unit/accounts/test_services.py @@ -408,6 +408,14 @@ def test_get_user_by_email_failure(self, user_service): assert found_user is None + def test_get_admins(self, user_service): + admin = UserFactory.create(is_superuser=True) + user = UserFactory.create(is_superuser=False) + admins = user_service.get_admins() + + assert admin in admins + assert user not in admins + def test_disable_password(self, user_service): user = UserFactory.create() diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py index da7d5d0487bd..084e3f48c7e2 100644 --- a/tests/unit/admin/test_routes.py +++ b/tests/unit/admin/test_routes.py @@ -26,6 +26,11 @@ def test_includeme(): assert config.add_route.calls == [ pretend.call("admin.dashboard", "/admin/", domain=warehouse), + pretend.call( + "admin.organization.approve", + "/admin/organizations/approve/", + domain=warehouse, + ), pretend.call("admin.user.list", "/admin/users/", domain=warehouse), pretend.call("admin.user.detail", "/admin/users/{user_id}/", domain=warehouse), pretend.call( diff --git a/tests/unit/admin/views/test_organizations.py b/tests/unit/admin/views/test_organizations.py new file mode 100644 index 000000000000..ae3a2639bf37 --- /dev/null +++ b/tests/unit/admin/views/test_organizations.py @@ -0,0 +1,20 @@ +# 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 + +from warehouse.admin.views import organizations as views + + +class TestOrganizations: + def test_approve(self): + assert views.approve(pretend.stub()) == {} diff --git a/tests/unit/email/test_init.py b/tests/unit/email/test_init.py index 5ae27303bb8e..3fda4908bdbd 100644 --- a/tests/unit/email/test_init.py +++ b/tests/unit/email/test_init.py @@ -413,6 +413,101 @@ def retry(exc): assert task.retry.calls == [pretend.call(exc=exc)] +class TestSendAdminNewOrganizationRequestedEmail: + def test_send_admin_new_organization_requested_email( + self, pyramid_request, pyramid_config, monkeypatch + ): + admin_user = pretend.stub( + id="admin", + username="admin", + name="PyPI Adminstrator", + email="admin@pypi.org", + primary_email=pretend.stub(email="admin@pypi.org", verified=True), + ) + initiator_user = pretend.stub( + id="id", + username="username", + name="", + email="email@example.com", + primary_email=pretend.stub(email="email@example.com", verified=True), + ) + organization_name = "example" + + subject_renderer = pyramid_config.testing_add_renderer( + "email/admin-new-organization-requested/subject.txt" + ) + subject_renderer.string_response = "Email Subject" + body_renderer = pyramid_config.testing_add_renderer( + "email/admin-new-organization-requested/body.txt" + ) + body_renderer.string_response = "Email Body" + html_renderer = pyramid_config.testing_add_renderer( + "email/admin-new-organization-requested/body.html" + ) + html_renderer.string_response = "Email HTML Body" + + send_email = pretend.stub( + delay=pretend.call_recorder(lambda *args, **kwargs: None) + ) + pyramid_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email) + monkeypatch.setattr(email, "send_email", send_email) + + pyramid_request.db = pretend.stub( + query=lambda a: pretend.stub( + filter=lambda *a: pretend.stub( + one=lambda: pretend.stub(user_id=admin_user.id) + ) + ), + ) + pyramid_request.user = initiator_user + pyramid_request.registry.settings = {"mail.sender": "noreply@example.com"} + + result = email.send_admin_new_organization_requested_email( + pyramid_request, + admin_user, + organization_name=organization_name, + initiator_username=initiator_user.username, + ) + + assert result == { + "organization_name": organization_name, + "initiator_username": initiator_user.username, + } + subject_renderer.assert_() + body_renderer.assert_( + organization_name=organization_name, + initiator_username=initiator_user.username, + ) + html_renderer.assert_( + organization_name=organization_name, + initiator_username=initiator_user.username, + ) + assert pyramid_request.task.calls == [pretend.call(send_email)] + assert send_email.delay.calls == [ + pretend.call( + f"{admin_user.name} <{admin_user.email}>", + { + "subject": "Email Subject", + "body_text": "Email Body", + "body_html": ( + "\n\n" + "

Email HTML Body

\n\n" + ), + }, + { + "tag": "account:email:sent", + "user_id": admin_user.id, + "additional": { + "from_": "noreply@example.com", + "to": admin_user.email, + "subject": "Email Subject", + "redact_ip": True, + }, + }, + ) + ] + + class TestSendPasswordResetEmail: @pytest.mark.parametrize( ("verified", "email_addr"), @@ -1260,6 +1355,84 @@ def test_primary_email_change_email_unverified( assert send_email.delay.calls == [] +class TestSendNewOrganizationRequestedEmail: + def test_send_new_organization_requested_email( + self, pyramid_request, pyramid_config, monkeypatch + ): + initiator_user = pretend.stub( + id="id", + username="username", + name="", + email="email@example.com", + primary_email=pretend.stub(email="email@example.com", verified=True), + ) + organization_name = "example" + + subject_renderer = pyramid_config.testing_add_renderer( + "email/new-organization-requested/subject.txt" + ) + subject_renderer.string_response = "Email Subject" + body_renderer = pyramid_config.testing_add_renderer( + "email/new-organization-requested/body.txt" + ) + body_renderer.string_response = "Email Body" + html_renderer = pyramid_config.testing_add_renderer( + "email/new-organization-requested/body.html" + ) + html_renderer.string_response = "Email HTML Body" + + send_email = pretend.stub( + delay=pretend.call_recorder(lambda *args, **kwargs: None) + ) + pyramid_request.task = pretend.call_recorder(lambda *args, **kwargs: send_email) + monkeypatch.setattr(email, "send_email", send_email) + + pyramid_request.db = pretend.stub( + query=lambda a: pretend.stub( + filter=lambda *a: pretend.stub( + one=lambda: pretend.stub(user_id=initiator_user.id) + ) + ), + ) + pyramid_request.user = initiator_user + pyramid_request.registry.settings = {"mail.sender": "noreply@example.com"} + + result = email.send_new_organization_requested_email( + pyramid_request, + initiator_user, + organization_name=organization_name, + ) + + assert result == {"organization_name": organization_name} + subject_renderer.assert_() + body_renderer.assert_(organization_name=organization_name) + html_renderer.assert_(organization_name=organization_name) + assert pyramid_request.task.calls == [pretend.call(send_email)] + assert send_email.delay.calls == [ + pretend.call( + f"{initiator_user.username} <{initiator_user.email}>", + { + "subject": "Email Subject", + "body_text": "Email Body", + "body_html": ( + "\n\n" + "

Email HTML Body

\n\n" + ), + }, + { + "tag": "account:email:sent", + "user_id": initiator_user.id, + "additional": { + "from_": "noreply@example.com", + "to": initiator_user.email, + "subject": "Email Subject", + "redact_ip": False, + }, + }, + ) + ] + + class TestCollaboratorAddedEmail: def test_collaborator_added_email( self, pyramid_request, pyramid_config, monkeypatch diff --git a/tests/unit/manage/test_forms.py b/tests/unit/manage/test_forms.py index bde69e26c7e6..2ab8ed35db95 100644 --- a/tests/unit/manage/test_forms.py +++ b/tests/unit/manage/test_forms.py @@ -501,6 +501,44 @@ def test_validate_macaroon_id(self): assert form.validate() +class TestCreateOrganizationForm: + def test_creation(self): + organization_service = pretend.stub() + form = forms.CreateOrganizationForm( + organization_service=organization_service, + ) + + assert form.organization_service is organization_service + + def test_validate_name_with_no_organization(self): + organization_service = pretend.stub( + find_organizationid=pretend.call_recorder(lambda name: None) + ) + form = forms.CreateOrganizationForm(organization_service=organization_service) + field = pretend.stub(data="my_organization_name") + forms._ = lambda string: string + + form.validate_name(field) + + assert organization_service.find_organizationid.calls == [ + pretend.call("my_organization_name") + ] + + def test_validate_name_with_organization(self): + organization_service = pretend.stub( + find_organizationid=pretend.call_recorder(lambda name: 1) + ) + form = forms.CreateOrganizationForm(organization_service=organization_service) + field = pretend.stub(data="my_organization_name") + + with pytest.raises(wtforms.validators.ValidationError): + form.validate_name(field) + + assert organization_service.find_organizationid.calls == [ + pretend.call("my_organization_name") + ] + + class TestSaveAccountForm: def test_public_email_verified(self): email = pretend.stub(verified=True, public=False, email="foo@example.com") diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index a2ae44ac1c80..6c97740d84f8 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -44,6 +44,7 @@ from warehouse.manage import views from warehouse.metrics.interfaces import IMetricsService from warehouse.oidc.interfaces import TooManyOIDCRegistrations +from warehouse.organizations.interfaces import IOrganizationService from warehouse.packaging.models import ( File, JournalEntry, @@ -77,12 +78,14 @@ class TestManageAccount: def test_default_response(self, monkeypatch, public_email, expected_public_email): breach_service = pretend.stub() user_service = pretend.stub() + organization_service = pretend.stub() name = pretend.stub() user_id = pretend.stub() request = pretend.stub( find_service=lambda iface, **kw: { IPasswordBreachedService: breach_service, IUserService: user_service, + IOrganizationService: organization_service, }[iface], user=pretend.stub(name=name, id=user_id, public_email=public_email), ) @@ -2302,6 +2305,296 @@ def test_delete_macaroon_records_events_for_each_project(self, monkeypatch): ] +class TestManageOrganizations: + def test_default_response(self, monkeypatch): + create_organization_obj = pretend.stub() + create_organization_cls = pretend.call_recorder( + lambda *a, **kw: create_organization_obj + ) + monkeypatch.setattr(views, "CreateOrganizationForm", create_organization_cls) + + request = pretend.stub( + user=pretend.stub(id=pretend.stub(), username=pretend.stub()), + find_service=lambda interface, **kw: { + IOrganizationService: pretend.stub(), + IUserService: pretend.stub(), + }[interface], + ) + + view = views.ManageOrganizationsViews(request) + + assert view.default_response == { + "create_organization_form": create_organization_obj, + } + + def test_manage_organizations(self, monkeypatch): + request = pretend.stub( + find_service=lambda *a, **kw: pretend.stub(), + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)), + ) + + default_response = {"default": "response"} + monkeypatch.setattr( + views.ManageOrganizationsViews, "default_response", default_response + ) + view = views.ManageOrganizationsViews(request) + result = view.manage_organizations() + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISABLE_ORGANIZATIONS), + ] + assert result == default_response + + def test_manage_organizations_disallow_organizations(self, monkeypatch): + request = pretend.stub( + find_service=lambda *a, **kw: pretend.stub(), + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: True)), + ) + + view = views.ManageOrganizationsViews(request) + with pytest.raises(HTTPNotFound): + view.manage_organizations() + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISABLE_ORGANIZATIONS), + ] + + def test_create_organization(self, monkeypatch): + admins = [] + user_service = pretend.stub( + get_admins=pretend.call_recorder(lambda *a, **kw: admins), + record_event=pretend.call_recorder(lambda *a, **kw: None), + ) + + organization = pretend.stub( + id=pretend.stub(), + name="psf", + display_name="Python Software Foundation", + orgtype="Community", + link_url="https://www.python.org/psf/", + description=( + "To promote, protect, and advance the Python programming " + "language, and to support and facilitate the growth of a " + "diverse and international community of Python programmers" + ), + is_active=False, + is_approved=None, + ) + catalog_entry = pretend.stub() + role = pretend.stub() + organization_service = pretend.stub( + add_organization=pretend.call_recorder(lambda *a, **kw: organization), + add_catalog_entry=pretend.call_recorder(lambda *a, **kw: catalog_entry), + add_organization_role=pretend.call_recorder(lambda *a, **kw: role), + record_event=pretend.call_recorder(lambda *a, **kw: None), + ) + + request = pretend.stub( + POST={ + "name": organization.name, + "display_name": organization.display_name, + "orgtype": organization.orgtype, + "link_url": organization.link_url, + "description": organization.description, + }, + domain=pretend.stub(), + user=pretend.stub( + id=pretend.stub(), + username=pretend.stub(), + has_primary_verified_email=True, + ), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + find_service=lambda interface, **kw: { + IUserService: user_service, + IOrganizationService: organization_service, + }[interface], + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)), + remote_addr="0.0.0.0", + ) + + create_organization_obj = pretend.stub(validate=lambda: True, data=request.POST) + create_organization_cls = pretend.call_recorder( + lambda *a, **kw: create_organization_obj + ) + monkeypatch.setattr(views, "CreateOrganizationForm", create_organization_cls) + + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr( + views, "send_admin_new_organization_requested_email", send_email + ) + monkeypatch.setattr(views, "send_new_organization_requested_email", send_email) + + default_response = {"default": "response"} + monkeypatch.setattr( + views.ManageOrganizationsViews, "default_response", default_response + ) + + view = views.ManageOrganizationsViews(request) + result = view.create_organization() + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISABLE_ORGANIZATIONS), + ] + assert user_service.get_admins.calls == [pretend.call()] + assert organization_service.add_organization.calls == [ + pretend.call( + name=organization.name, + display_name=organization.display_name, + orgtype=organization.orgtype, + link_url=organization.link_url, + description=organization.description, + ) + ] + assert organization_service.add_catalog_entry.calls == [ + pretend.call( + organization.name, + organization.id, + ) + ] + assert organization_service.add_organization_role.calls == [ + pretend.call( + "Owner", + request.user.id, + organization.id, + ) + ] + assert organization_service.record_event.calls == [ + pretend.call( + organization.id, + tag="organization:create", + additional={"created_by_user_id": str(request.user.id)}, + ), + pretend.call( + organization.id, + tag="organization:catalog_entry:add", + additional={"submitted_by_user_id": str(request.user.id)}, + ), + pretend.call( + organization.id, + tag="organization:organization_role:invite", + additional={ + "submitted_by_user_id": str(request.user.id), + "role_name": "Owner", + "target_user_id": str(request.user.id), + }, + ), + pretend.call( + organization.id, + tag="organization:organization_role:accepted", + additional={ + "submitted_by_user_id": str(request.user.id), + "role_name": "Owner", + "target_user_id": str(request.user.id), + }, + ), + ] + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:organization_role:accepted", + additional={ + "submitted_by_user_id": str(request.user.id), + "organization_name": organization.name, + "role_name": "Owner", + }, + ), + ] + assert send_email.calls == [ + pretend.call( + request, + admins, + organization_name=organization.name, + initiator_username=request.user.username, + ), + pretend.call( + request, + request.user, + organization_name=organization.name, + ), + ] + assert result == default_response + + def test_create_organization_validation_fails(self, monkeypatch): + admins = [] + user_service = pretend.stub( + get_admins=pretend.call_recorder(lambda *a, **kw: admins), + record_event=pretend.call_recorder(lambda *a, **kw: None), + ) + + organization = pretend.stub() + catalog_entry = pretend.stub() + role = pretend.stub() + organization_service = pretend.stub( + add_organization=pretend.call_recorder(lambda *a, **kw: organization), + add_catalog_entry=pretend.call_recorder(lambda *a, **kw: catalog_entry), + add_organization_role=pretend.call_recorder(lambda *a, **kw: role), + record_event=pretend.call_recorder(lambda *a, **kw: None), + ) + + request = pretend.stub( + POST={ + "name": None, + "display_name": None, + "orgtype": None, + "link_url": None, + "description": None, + }, + domain=pretend.stub(), + user=pretend.stub( + id=pretend.stub(), + username=pretend.stub(), + has_primary_verified_email=True, + ), + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + find_service=lambda interface, **kw: { + IUserService: user_service, + IOrganizationService: organization_service, + }[interface], + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)), + remote_addr="0.0.0.0", + ) + + create_organization_obj = pretend.stub( + validate=lambda: False, data=request.POST + ) + create_organization_cls = pretend.call_recorder( + lambda *a, **kw: create_organization_obj + ) + monkeypatch.setattr(views, "CreateOrganizationForm", create_organization_cls) + + send_email = pretend.call_recorder(lambda *a, **kw: None) + monkeypatch.setattr( + views, "send_admin_new_organization_requested_email", send_email + ) + monkeypatch.setattr(views, "send_new_organization_requested_email", send_email) + + view = views.ManageOrganizationsViews(request) + result = view.create_organization() + + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISABLE_ORGANIZATIONS), + ] + assert user_service.get_admins.calls == [] + assert organization_service.add_organization.calls == [] + assert organization_service.add_catalog_entry.calls == [] + assert organization_service.add_organization_role.calls == [] + assert organization_service.record_event.calls == [] + assert send_email.calls == [] + assert result == {"create_organization_form": create_organization_obj} + + def test_create_organizations_disallow_organizations(self, monkeypatch): + request = pretend.stub( + find_service=lambda *a, **kw: pretend.stub(), + flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: True)), + ) + + view = views.ManageOrganizationsViews(request) + with pytest.raises(HTTPNotFound): + view.create_organization() + assert request.flags.enabled.calls == [ + pretend.call(AdminFlagValue.DISABLE_ORGANIZATIONS), + ] + + class TestManageProjects: def test_manage_projects(self, db_request): older_release = ReleaseFactory(created=datetime.datetime(2015, 1, 1)) diff --git a/tests/unit/organizations/__init__.py b/tests/unit/organizations/__init__.py new file mode 100644 index 000000000000..9b5fe147153d --- /dev/null +++ b/tests/unit/organizations/__init__.py @@ -0,0 +1,18 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from warehouse.organizations.interfaces import IOrganizationService +from warehouse.organizations.services import database_organization_factory + + +def includeme(config): + config.register_service_factory(database_organization_factory, IOrganizationService) diff --git a/tests/unit/organizations/test_services.py b/tests/unit/organizations/test_services.py new file mode 100644 index 000000000000..e2809a64ff67 --- /dev/null +++ b/tests/unit/organizations/test_services.py @@ -0,0 +1,122 @@ +# 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 + +from zope.interface.verify import verifyClass + +from warehouse.organizations import services +from warehouse.organizations.interfaces import IOrganizationService +from warehouse.organizations.models import OrganizationRoleType + +from ...common.db.organizations import OrganizationFactory, UserFactory + + +def test_database_organizations_factory(): + db = pretend.stub() + remote_addr = pretend.stub() + context = pretend.stub() + request = pretend.stub(db=db, remote_addr=remote_addr) + + service = services.database_organization_factory(context, request) + assert service.db is db + assert service.remote_addr is remote_addr + + +class TestDatabaseOrganizationService: + def test_verify_service(self): + assert verifyClass(IOrganizationService, services.DatabaseOrganizationService) + + def test_service_creation(self, remote_addr): + session = pretend.stub() + service = services.DatabaseOrganizationService(session, remote_addr=remote_addr) + + assert service.db is session + assert service.remote_addr is remote_addr + + def test_get_organization(self, organization_service): + organization = OrganizationFactory.create() + assert organization_service.get_organization(organization.id) == organization + + def test_get_organization_by_name(self, organization_service): + organization = OrganizationFactory.create() + assert ( + organization_service.get_organization_by_name(organization.name) + == organization + ) + + def test_find_organizationid(self, organization_service): + organization = OrganizationFactory.create() + assert ( + organization_service.find_organizationid(organization.name) + == organization.id + ) + + def test_find_organizationid_nonexistent_org(self, organization_service): + assert organization_service.find_organizationid("a_spoon_in_the_matrix") is None + + def test_add_organization(self, organization_service): + organization = OrganizationFactory.create() + new_org = organization_service.add_organization( + name=organization.name, + display_name=organization.display_name, + orgtype=organization.orgtype, + link_url=organization.link_url, + description=organization.description, + ) + organization_service.db.flush() + org_from_db = organization_service.get_organization(new_org.id) + + assert org_from_db.name == organization.name + assert org_from_db.display_name == organization.display_name + assert org_from_db.orgtype == organization.orgtype + assert org_from_db.link_url == organization.link_url + assert org_from_db.description == organization.description + assert not org_from_db.is_active + + def test_add_catalog_entry(self, organization_service): + organization = OrganizationFactory.create() + + catalog_entry = organization_service.add_catalog_entry( + organization.normalized_name, organization.id + ) + assert catalog_entry.normalized_name == organization.normalized_name + assert catalog_entry.organization_id == organization.id + + def test_add_organization_role(self, organization_service, user_service): + user = UserFactory.create() + organization = OrganizationFactory.create() + + added_role = organization_service.add_organization_role( + OrganizationRoleType.Owner.value, user.id, organization.id + ) + assert added_role.role_name == OrganizationRoleType.Owner.value + assert added_role.user_id == user.id + assert added_role.organization_id == organization.id + + def test_approve_organization(self, organization_service): + organization = OrganizationFactory.create() + organization_service.approve_organization(organization.id) + + assert organization.is_active is True + assert organization.is_approved is True + assert organization.date_approved is not None + + def test_decline_organization(self, organization_service): + organization = OrganizationFactory.create() + organization_service.decline_organization(organization.id) + + assert organization.is_approved is False + assert organization.date_approved is not None + + # def test_record_event(self, organization_id, *, tag, additional=None): + # raise NotImplementedError diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index e194c2becfe6..7406cd0eeffc 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -352,6 +352,7 @@ def __init__(self): pretend.call(".oidc"), pretend.call(".malware"), pretend.call(".manage"), + pretend.call(".organizations"), pretend.call(".packaging"), pretend.call(".redirects"), pretend.call(".routes"), diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index c4aac6c899a0..dcccf822e5d8 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -237,6 +237,9 @@ def add_policy(name, filename): pretend.call( "manage.account.token", "/manage/account/token/", domain=warehouse ), + pretend.call( + "manage.organizations", "/manage/organizations/", domain=warehouse + ), pretend.call("manage.projects", "/manage/projects/", domain=warehouse), pretend.call( "manage.project.settings", diff --git a/warehouse/accounts/interfaces.py b/warehouse/accounts/interfaces.py index adf68da13042..151766d30eda 100644 --- a/warehouse/accounts/interfaces.py +++ b/warehouse/accounts/interfaces.py @@ -78,6 +78,12 @@ def get_user_by_email(email): if there is no user with that email. """ + def get_admins(): + """ + Return a list of user objects corresponding with admin users, or [] + if there is no admin users. + """ + def find_userid(username): """ Find the unique user identifier for the given username or None if there diff --git a/warehouse/accounts/services.py b/warehouse/accounts/services.py index a7a14950b7f3..616aad903e1e 100644 --- a/warehouse/accounts/services.py +++ b/warehouse/accounts/services.py @@ -101,6 +101,10 @@ def get_user_by_email(self, email): user_id = self.find_userid_by_email(email) return None if user_id is None else self.get_user(user_id) + @functools.lru_cache() + def get_admins(self): + return self.db.query(User).filter(User.is_superuser.is_(True)).all() + @functools.lru_cache() def find_userid(self, username): try: diff --git a/warehouse/admin/flags.py b/warehouse/admin/flags.py index 03082747c611..408b1f9d8e3a 100644 --- a/warehouse/admin/flags.py +++ b/warehouse/admin/flags.py @@ -18,6 +18,7 @@ class AdminFlagValue(enum.Enum): + DISABLE_ORGANIZATIONS = "disable-organizations" DISALLOW_DELETION = "disallow-deletion" DISALLOW_NEW_PROJECT_REGISTRATION = "disallow-new-project-registration" DISALLOW_NEW_UPLOAD = "disallow-new-upload" diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py index 8248f76290b7..ed7e8461db12 100644 --- a/warehouse/admin/routes.py +++ b/warehouse/admin/routes.py @@ -20,6 +20,11 @@ def includeme(config): # General Admin pages config.add_route("admin.dashboard", "/admin/", domain=warehouse) + # Organization related Admin pages + config.add_route( + "admin.organization.approve", "/admin/organizations/approve/", domain=warehouse + ) + # User related Admin pages config.add_route("admin.user.list", "/admin/users/", domain=warehouse) config.add_route("admin.user.detail", "/admin/users/{user_id}/", domain=warehouse) diff --git a/warehouse/admin/templates/admin/organizations/approve.html b/warehouse/admin/templates/admin/organizations/approve.html new file mode 100644 index 000000000000..ce105ad83fdf --- /dev/null +++ b/warehouse/admin/templates/admin/organizations/approve.html @@ -0,0 +1,26 @@ +{# + # 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 "confirm-action.html" %} + +{# This is a placeholder so we can reference `admin.organization.approve` + # as a route in the admin-new-organization-requested email. +-#} + +{% block title %} + {% trans %}Approve New Organization{% endtrans %} +{% endblock %} + +{% block main %} + +{% endblock %} diff --git a/warehouse/admin/views/organizations.py b/warehouse/admin/views/organizations.py new file mode 100644 index 000000000000..2750a5d6e4f7 --- /dev/null +++ b/warehouse/admin/views/organizations.py @@ -0,0 +1,28 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pyramid.view import view_config + + +# This is a placeholder so we can reference `admin.organization.approve` +# as a route in the admin-new-organization-requested email. +@view_config( + route_name="admin.organization.approve", + renderer="admin/organizations/approve.html", + permission="admin", + require_methods=False, + uses_session=True, + has_translations=True, +) +def approve(request): + # TODO + return {} diff --git a/warehouse/config.py b/warehouse/config.py index 16d09981286e..8fd0866cece7 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -518,6 +518,9 @@ def configure(settings=None): # Register logged-in views config.include(".manage") + # Register our organization support. + config.include(".organizations") + # Allow the packaging app to register any services it has. config.include(".packaging") diff --git a/warehouse/email/__init__.py b/warehouse/email/__init__.py index 752dc4392887..3a564cc2d043 100644 --- a/warehouse/email/__init__.py +++ b/warehouse/email/__init__.py @@ -186,6 +186,22 @@ def wrapper(request, user_or_users, **kwargs): return inner +# Email templates for administrators. + + +@_email("admin-new-organization-requested") +def send_admin_new_organization_requested_email( + request, user, *, organization_name, initiator_username +): + return { + "initiator_username": initiator_username, + "organization_name": organization_name, + } + + +# Email templates for users. + + @_email("password-reset", allow_unverified=True) def send_password_reset_email(request, user_and_email): user, _ = user_and_email @@ -267,6 +283,11 @@ def send_primary_email_change_email(request, user_and_email): } +@_email("new-organization-requested") +def send_new_organization_requested_email(request, user, *, organization_name): + return {"organization_name": organization_name} + + @_email("collaborator-added") def send_collaborator_added_email( request, email_recipients, *, user, submitter, project_name, role diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 3703cc3fcc24..33de5fb6767f 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -65,7 +65,7 @@ msgid "" "different email." msgstr "" -#: warehouse/accounts/forms.py:258 warehouse/manage/forms.py:73 +#: warehouse/accounts/forms.py:258 warehouse/manage/forms.py:75 msgid "The name is too long. Choose a name with 100 characters or less." msgstr "" @@ -116,7 +116,7 @@ msgstr "" msgid "Successful WebAuthn assertion" msgstr "" -#: warehouse/accounts/views.py:441 warehouse/manage/views.py:813 +#: warehouse/accounts/views.py:441 warehouse/manage/views.py:817 msgid "Recovery code accepted. The supplied code cannot be used again." msgstr "" @@ -225,51 +225,91 @@ msgstr "" msgid "Banner Preview" msgstr "" -#: warehouse/manage/views.py:244 +#: warehouse/admin/templates/admin/organizations/approve.html:21 +msgid "Approve New Organization" +msgstr "" + +#: warehouse/manage/forms.py:322 +msgid "Choose an organization account name with 50 characters or less." +msgstr "" + +#: warehouse/manage/forms.py:330 +msgid "" +"The organization account name is invalid. Organization account names must" +" be composed of letters, numbers, dots, hyphens and underscores. And must" +" also start and finish with a letter or number. Choose a different " +"organization account name." +msgstr "" + +#: warehouse/manage/forms.py:345 +msgid "" +"This organization account name is already being used by another account. " +"Choose a different organization account name." +msgstr "" + +#: warehouse/manage/forms.py:366 +msgid "" +"The organization name is too long. Choose a organization name with 100 " +"characters or less." +msgstr "" + +#: warehouse/manage/forms.py:378 +msgid "" +"The organization URL is too long. Choose a organization URL with 400 " +"characters or less." +msgstr "" + +#: warehouse/manage/forms.py:392 +msgid "" +"The organization description is too long. Choose a organization " +"description with 400 characters or less." +msgstr "" + +#: warehouse/manage/views.py:248 msgid "Email ${email_address} added - check your email for a verification link" msgstr "" -#: warehouse/manage/views.py:761 +#: warehouse/manage/views.py:765 msgid "Recovery codes already generated" msgstr "" -#: warehouse/manage/views.py:762 +#: warehouse/manage/views.py:766 msgid "Generating new recovery codes will invalidate your existing codes." msgstr "" -#: warehouse/manage/views.py:1190 +#: warehouse/manage/views.py:1300 msgid "" "There have been too many attempted OpenID Connect registrations. Try " "again later." msgstr "" -#: warehouse/manage/views.py:1871 +#: warehouse/manage/views.py:1981 msgid "User '${username}' already has ${role_name} role for project" msgstr "" -#: warehouse/manage/views.py:1882 +#: warehouse/manage/views.py:1992 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:1895 +#: warehouse/manage/views.py:2005 msgid "User '${username}' already has an active invite. Please try again later." msgstr "" -#: warehouse/manage/views.py:1953 +#: warehouse/manage/views.py:2063 msgid "Invitation sent to '${username}'" msgstr "" -#: warehouse/manage/views.py:2000 +#: warehouse/manage/views.py:2110 msgid "Could not find role invitation." msgstr "" -#: warehouse/manage/views.py:2011 +#: warehouse/manage/views.py:2121 msgid "Invitation already expired." msgstr "" -#: warehouse/manage/views.py:2035 +#: warehouse/manage/views.py:2145 msgid "Invitation revoked from '${username}'." msgstr "" @@ -896,6 +936,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/publishing.html:85 #: warehouse/templates/manage/publishing.html:97 #: warehouse/templates/manage/publishing.html:109 @@ -1329,6 +1374,19 @@ msgid "" "to publish." msgstr "" +#: warehouse/templates/email/new-organization-requested/body.html:17 +#, python-format +msgid "" +"Your request for a new PyPI organization named \"%(organization_name)s\" " +"has been submitted." +msgstr "" + +#: warehouse/templates/email/new-organization-requested/body.html:19 +msgid "" +"You will receive another email when the PyPI organization has been " +"approved." +msgstr "" + #: warehouse/templates/email/oidc-provider-added/body.html:19 #, python-format msgid "" @@ -2916,6 +2974,70 @@ msgstr "" msgid "Back to projects" msgstr "" +#: warehouse/templates/manage/organizations.html:18 +msgid "Your organizations" +msgstr "" + +#: warehouse/templates/manage/organizations.html:25 +msgid "Create new organization" +msgstr "" + +#: warehouse/templates/manage/organizations.html:32 +msgid "Organization account name" +msgstr "" + +#: warehouse/templates/manage/organizations.html:37 +msgid "Select an organization account name" +msgstr "" + +#: warehouse/templates/manage/organizations.html:48 +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 +msgid "For example" +msgstr "" + +#: warehouse/templates/manage/organizations.html:55 +msgid "Organization name" +msgstr "" + +#: warehouse/templates/manage/organizations.html:60 +msgid "Name of your business, product, or project" +msgstr "" + +#: warehouse/templates/manage/organizations.html:77 +msgid "️Organization URL" +msgstr "" + +#: warehouse/templates/manage/organizations.html:83 +msgid "URL for your business, product, or project" +msgstr "" + +#: warehouse/templates/manage/organizations.html:95 +msgid "Organization description" +msgstr "" + +#: warehouse/templates/manage/organizations.html:100 +msgid "Description of your business, product, or project" +msgstr "" + +#: warehouse/templates/manage/organizations.html:114 +msgid "️Organization type" +msgstr "" + +#: warehouse/templates/manage/organizations.html:126 +msgid "" +"Companies can create organization accounts as a paid service while " +"community projects are granted complimentary access." +msgstr "" + +#: warehouse/templates/manage/organizations.html:132 +msgid "Create" +msgstr "" + #: warehouse/templates/manage/projects.html:22 #, python-format msgid "Pending invitations (%(project_count)s)" diff --git a/warehouse/manage/forms.py b/warehouse/manage/forms.py index d9aa24933d67..5f2ceceb0303 100644 --- a/warehouse/manage/forms.py +++ b/warehouse/manage/forms.py @@ -27,6 +27,8 @@ ) from warehouse.i18n import localize as _ +# /manage/account/ forms + class RoleNameMixin: @@ -303,3 +305,100 @@ class Toggle2FARequirementForm(forms.Form): __params__ = ["two_factor_requirement_sentinel"] two_factor_requirement_sentinel = wtforms.HiddenField() + + +# /manage/organizations/ forms + + +class NewOrganizationNameMixin: + + name = wtforms.StringField( + validators=[ + wtforms.validators.DataRequired( + message="Specify organization account name" + ), + wtforms.validators.Length( + max=50, + message=_( + "Choose an organization account name with 50 characters or less." + ), + ), + # the regexp below must match the CheckConstraint + # for the name field in organizations.model.Organization + wtforms.validators.Regexp( + r"^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$", + message=_( + "The organization account name is invalid. " + "Organization account names " + "must be composed of letters, numbers, " + "dots, hyphens and underscores. And must " + "also start and finish with a letter or number. " + "Choose a different organization account name." + ), + ), + ] + ) + + def validate_name(self, field): + if self.organization_service.find_organizationid(field.data) is not None: + raise wtforms.validators.ValidationError( + _( + "This organization account name is already being " + "used by another account. Choose a different " + "organization account name." + ) + ) + + +class CreateOrganizationForm(forms.Form, NewOrganizationNameMixin): + + __params__ = ["name", "display_name", "link_url", "description", "orgtype"] + + def __init__(self, *args, organization_service, **kwargs): + super().__init__(*args, **kwargs) + self.organization_service = organization_service + + display_name = wtforms.StringField( + validators=[ + wtforms.validators.DataRequired(message="Specify your organization name"), + wtforms.validators.Length( + max=100, + message=_( + "The organization name is too long. " + "Choose a organization name with 100 characters or less." + ), + ), + ] + ) + link_url = wtforms.URLField( + validators=[ + wtforms.validators.DataRequired(message="Specify your organization URL"), + wtforms.validators.Length( + max=400, + message=_( + "The organization URL is too long. " + "Choose a organization URL with 400 characters or less." + ), + ), + ] + ) + description = wtforms.TextAreaField( + validators=[ + wtforms.validators.DataRequired( + message="Specify your organization description" + ), + wtforms.validators.Length( + max=400, + message=_( + "The organization description is too long. " + "Choose a organization description with 400 characters or less." + ), + ), + ] + ) + orgtype = wtforms.SelectField( + choices=[("Company", "Company"), ("Community", "Community")], + validators=[ + wtforms.validators.DataRequired(message="Select organization type"), + ], + ) diff --git a/warehouse/manage/views.py b/warehouse/manage/views.py index d38577199c16..423318ecb924 100644 --- a/warehouse/manage/views.py +++ b/warehouse/manage/views.py @@ -43,9 +43,11 @@ from warehouse.admin.flags import AdminFlagValue from warehouse.email import ( send_account_deletion_email, + send_admin_new_organization_requested_email, send_collaborator_removed_email, send_collaborator_role_changed_email, send_email_verification_email, + send_new_organization_requested_email, send_oidc_provider_added_email, send_oidc_provider_removed_email, send_password_change_email, @@ -70,6 +72,7 @@ ChangeRoleForm, ConfirmPasswordForm, CreateMacaroonForm, + CreateOrganizationForm, CreateRoleForm, DeleteMacaroonForm, DeleteTOTPForm, @@ -83,6 +86,7 @@ from warehouse.oidc.forms import DeleteProviderForm, GitHubProviderForm from warehouse.oidc.interfaces import TooManyOIDCRegistrations from warehouse.oidc.models import GitHubProvider, OIDCProvider +from warehouse.organizations.interfaces import IOrganizationService from warehouse.packaging.models import ( File, JournalEntry, @@ -968,6 +972,112 @@ def delete_macaroon(self): return HTTPSeeOther(redirect_to) +@view_defaults( + route_name="manage.organizations", + renderer="manage/organizations.html", + uses_session=True, + require_csrf=True, + require_methods=False, + permission="manage:user", + has_translations=True, +) +class ManageOrganizationsViews: + def __init__(self, request): + self.request = request + self.user_service = request.find_service(IUserService, context=None) + self.organization_service = request.find_service( + IOrganizationService, context=None + ) + + @property + def default_response(self): + return { + "create_organization_form": CreateOrganizationForm( + organization_service=self.organization_service, + ), + } + + @view_config(request_method="GET") + def manage_organizations(self): + if self.request.flags.enabled(AdminFlagValue.DISABLE_ORGANIZATIONS): + raise HTTPNotFound + + return self.default_response + + @view_config(request_method="POST", request_param=CreateOrganizationForm.__params__) + def create_organization(self): + if self.request.flags.enabled(AdminFlagValue.DISABLE_ORGANIZATIONS): + raise HTTPNotFound + + form = CreateOrganizationForm( + self.request.POST, + organization_service=self.organization_service, + ) + + if form.validate(): + data = form.data + organization = self.organization_service.add_organization(**data) + self.organization_service.record_event( + organization.id, + tag="organization:create", + additional={"created_by_user_id": str(self.request.user.id)}, + ) + self.organization_service.add_catalog_entry( + organization.name, organization.id + ) + self.organization_service.record_event( + organization.id, + tag="organization:catalog_entry:add", + additional={"submitted_by_user_id": str(self.request.user.id)}, + ) + self.organization_service.add_organization_role( + "Owner", self.request.user.id, organization.id + ) + self.organization_service.record_event( + organization.id, + tag="organization:organization_role:invite", + additional={ + "submitted_by_user_id": str(self.request.user.id), + "role_name": "Owner", + "target_user_id": str(self.request.user.id), + }, + ) + self.organization_service.record_event( + organization.id, + tag="organization:organization_role:accepted", + additional={ + "submitted_by_user_id": str(self.request.user.id), + "role_name": "Owner", + "target_user_id": str(self.request.user.id), + }, + ) + self.user_service.record_event( + self.request.user.id, + tag="account:organization_role:accepted", + additional={ + "submitted_by_user_id": str(self.request.user.id), + "organization_name": organization.name, + "role_name": "Owner", + }, + ) + send_admin_new_organization_requested_email( + self.request, + self.user_service.get_admins(), + organization_name=organization.name, + initiator_username=self.request.user.username, + ) + send_new_organization_requested_email( + self.request, self.request.user, organization_name=organization.name + ) + self.request.session.flash( + "Request for new organization submitted", queue="success" + ) + else: + return {"create_organization_form": form} + + return self.default_response + + @view_config( route_name="manage.projects", renderer="manage/projects.html", diff --git a/warehouse/migrations/versions/4a985d158c3c_add_organization_events_table.py b/warehouse/migrations/versions/4a985d158c3c_add_organization_events_table.py new file mode 100644 index 000000000000..76c39bd2153e --- /dev/null +++ b/warehouse/migrations/versions/4a985d158c3c_add_organization_events_table.py @@ -0,0 +1,78 @@ +# 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_organization_events_table + +Revision ID: 4a985d158c3c +Revises: 614a7fcb40ed +Create Date: 2022-04-14 02:25:50.805348 +""" + +import sqlalchemy as sa + +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "4a985d158c3c" +down_revision = "614a7fcb40ed" + +# Note: It is VERY important to ensure that a migration does not lock for a +# long period of time and to ensure that each individual migration does +# not break compatibility with the *previous* version of the code base. +# This is because the migrations will be ran automatically as part of the +# deployment process, but while the previous version of the code is still +# up and running. Thus backwards incompatible changes must be broken up +# over multiple migrations inside of multiple pull requests in order to +# phase them in over multiple deploys. + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "organization_events", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("source_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("tag", sa.String(), nullable=False), + sa.Column( + "time", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("ip_address", sa.String(), nullable=False), + sa.Column("additional", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint( + ["source_id"], + ["organizations.id"], + initially="DEFERRED", + deferrable=True, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_organization_events_source_id"), + "organization_events", + ["source_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_organization_events_source_id"), table_name="organization_events" + ) + op.drop_table("organization_events") + # ### end Alembic commands ### diff --git a/warehouse/migrations/versions/614a7fcb40ed_create_organization_models.py b/warehouse/migrations/versions/614a7fcb40ed_create_organization_models.py new file mode 100644 index 000000000000..448ab93d573d --- /dev/null +++ b/warehouse/migrations/versions/614a7fcb40ed_create_organization_models.py @@ -0,0 +1,271 @@ +# 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. +""" +Create Organization models + +Revision ID: 614a7fcb40ed +Revises: 5e02c4f9f95c +Create Date: 2022-04-13 17:23:17.396325 +""" + +import sqlalchemy as sa +import sqlalchemy_utils + +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "614a7fcb40ed" +down_revision = "5e02c4f9f95c" + +# Note: It is VERY important to ensure that a migration does not lock for a +# long period of time and to ensure that each individual migration does +# not break compatibility with the *previous* version of the code base. +# This is because the migrations will be ran automatically as part of the +# deployment process, but while the previous version of the code is still +# up and running. Thus backwards incompatible changes must be broken up +# over multiple migrations inside of multiple pull requests in order to +# phase them in over multiple deploys. + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "organizations", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("display_name", sa.Text(), nullable=False), + sa.Column("orgtype", sa.Text(), nullable=False), + sa.Column("link_url", sqlalchemy_utils.types.url.URLType(), nullable=False), + sa.Column("description", sa.Text(), nullable=False), + sa.Column( + "is_active", sa.Boolean(), nullable=False, server_default=sa.sql.false() + ), + sa.Column("is_approved", sa.Boolean(), nullable=True), + sa.Column( + "created", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("date_approved", sa.DateTime(), nullable=True), + sa.CheckConstraint( + "name ~* '^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$'::text", + name="organizations_valid_name", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_organizations_created"), "organizations", ["created"], unique=False + ) + op.create_table( + "organization_invitations", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("invite_status", sa.Text(), nullable=False), + sa.Column("token", sa.Text(), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], onupdate="CASCADE", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", + "organization_id", + name="_organization_invitations_user_organization_uc", + ), + ) + op.create_index( + op.f("ix_organization_invitations_organization_id"), + "organization_invitations", + ["organization_id"], + unique=False, + ) + op.create_index( + op.f("ix_organization_invitations_user_id"), + "organization_invitations", + ["user_id"], + unique=False, + ) + op.create_index( + "organization_invitations_user_id_idx", + "organization_invitations", + ["user_id"], + unique=False, + ) + op.create_table( + "organization_name_catalog", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("normalized_name", sa.Text(), nullable=False), + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "normalized_name", + "organization_id", + name="_organization_name_catalog_normalized_name_organization_uc", + ), + ) + op.create_index( + "organization_name_catalog_normalized_name_idx", + "organization_name_catalog", + ["normalized_name"], + unique=False, + ) + op.create_index( + "organization_name_catalog_organization_id_idx", + "organization_name_catalog", + ["organization_id"], + unique=False, + ) + op.create_table( + "organization_project", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column( + "is_active", sa.Boolean(), nullable=False, server_default=sa.sql.false() + ), + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["project_id"], ["projects.id"], onupdate="CASCADE", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "organization_id", + "project_id", + name="_organization_project_organization_project_uc", + ), + ) + op.create_index( + "organization_project_organization_id_idx", + "organization_project", + ["organization_id"], + unique=False, + ) + op.create_index( + "organization_project_project_id_idx", + "organization_project", + ["project_id"], + unique=False, + ) + op.create_table( + "organization_roles", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("role_name", sa.Text(), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], onupdate="CASCADE", ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", + "organization_id", + name="_organization_roles_user_organization_uc", + ), + ) + op.create_index( + "organization_roles_organization_id_idx", + "organization_roles", + ["organization_id"], + unique=False, + ) + op.create_index( + "organization_roles_user_id_idx", + "organization_roles", + ["user_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("organization_roles_user_id_idx", table_name="organization_roles") + op.drop_index( + "organization_roles_organization_id_idx", table_name="organization_roles" + ) + op.drop_table("organization_roles") + op.drop_index( + "organization_project_project_id_idx", table_name="organization_project" + ) + op.drop_index( + "organization_project_organization_id_idx", table_name="organization_project" + ) + op.drop_table("organization_project") + op.drop_index( + "organization_name_catalog_organization_id_idx", + table_name="organization_name_catalog", + ) + op.drop_index( + "organization_name_catalog_name_idx", table_name="organization_name_catalog" + ) + op.drop_table("organization_name_catalog") + op.drop_index( + "organization_invitations_user_id_idx", table_name="organization_invitations" + ) + op.drop_index( + op.f("ix_organization_invitations_user_id"), + table_name="organization_invitations", + ) + op.drop_index( + op.f("ix_organization_invitations_organization_id"), + table_name="organization_invitations", + ) + op.drop_table("organization_invitations") + op.drop_index(op.f("ix_organizations_created"), table_name="organizations") + op.drop_table("organizations") + # ### end Alembic commands ### diff --git a/warehouse/migrations/versions/9f0f99509d92_add_disable_organizations_flag.py b/warehouse/migrations/versions/9f0f99509d92_add_disable_organizations_flag.py new file mode 100644 index 000000000000..98bb0f79da5c --- /dev/null +++ b/warehouse/migrations/versions/9f0f99509d92_add_disable_organizations_flag.py @@ -0,0 +1,55 @@ +# 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 disable-organizations AdminFlag + +Revision ID: 9f0f99509d92 +Revises: 4a985d158c3c +Create Date: 2022-04-18 02:04:40.318843 +""" + +from alembic import op + +revision = "9f0f99509d92" +down_revision = "4a985d158c3c" + +# Note: It is VERY important to ensure that a migration does not lock for a +# long period of time and to ensure that each individual migration does +# not break compatibility with the *previous* version of the code base. +# This is because the migrations will be ran automatically as part of the +# deployment process, but while the previous version of the code is still +# up and running. Thus backwards incompatible changes must be broken up +# over multiple migrations inside of multiple pull requests in order to +# phase them in over multiple deploys. + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute( + """ + INSERT INTO admin_flags(id, description, enabled, notify) + VALUES ( + 'disable-organizations', + 'Disallow ALL functionality for Organizations', + TRUE, + FALSE + ) + """ + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.execute("DELETE FROM admin_flags WHERE id = 'disable-organizations'") + + # ### end Alembic commands ### diff --git a/warehouse/organizations/__init__.py b/warehouse/organizations/__init__.py new file mode 100644 index 000000000000..d89e3a731c2f --- /dev/null +++ b/warehouse/organizations/__init__.py @@ -0,0 +1,19 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from warehouse.organizations.interfaces import IOrganizationService +from warehouse.organizations.services import database_organization_factory + + +def includeme(config): + # Register our organization service + config.register_service_factory(database_organization_factory, IOrganizationService) diff --git a/warehouse/organizations/interfaces.py b/warehouse/organizations/interfaces.py new file mode 100644 index 000000000000..2e72d7bcadc0 --- /dev/null +++ b/warehouse/organizations/interfaces.py @@ -0,0 +1,67 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from zope.interface import Interface + + +class IOrganizationService(Interface): + def get_organization(organization_id): + """ + Return the organization object that represents the given organizationid, or None if + there is no organization for that ID. + """ + + def get_organization_by_name(name): + """ + Return the organization object corresponding with the given organization name, or None + if there is no organization with that name. + """ + + def find_organizationid(name): + """ + Find the unique organization identifier for the given name or None if there + is no organization with the given name. + """ + + def add_organization(name, display_name, orgtype, link_url, description): + """ + Accepts a organization object, and attempts to create an organization with those + attributes. + """ + + def add_catalog_entry(name, organization_id): + """ + Adds the organization name to the organization name catalog + """ + + def add_organization_role(role_name, user_id, organization_id): + """ + Adds the organization role to the specified user and org + """ + + def approve_organization(organization_id): + """ + Performs operations necessary to approve an organization + """ + + def decline_organization(organization_id): + """ + Performs operations necessary to reject approval of an organization + """ + + def record_event(organization_id, *, tag, additional=None): + """ + Creates a new Organization.Event for the given organization with the given + tag, IP address, and additional metadata. + + Returns the event. + """ diff --git a/warehouse/organizations/models.py b/warehouse/organizations/models.py new file mode 100644 index 000000000000..e094488d47d3 --- /dev/null +++ b/warehouse/organizations/models.py @@ -0,0 +1,241 @@ +# 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 enum + +from sqlalchemy import ( + Boolean, + CheckConstraint, + Column, + DateTime, + Enum, + ForeignKey, + Index, + Text, + UniqueConstraint, + func, + orm, + sql, +) + +# from sqlalchemy.orm.exc import NoResultFound +from sqlalchemy_utils.types.url import URLType + +from warehouse import db +from warehouse.accounts.models import User +from warehouse.events.models import HasEvents +from warehouse.utils.attrs import make_repr + + +class OrganizationRoleType(enum.Enum): + + BillingManager = "Billing Manager" + Manager = "Manager" + Member = "Member" + Owner = "Owner" + + +class OrganizationRole(db.Model): + + __tablename__ = "organization_roles" + __table_args__ = ( + Index("organization_roles_user_id_idx", "user_id"), + Index("organization_roles_organization_id_idx", "organization_id"), + UniqueConstraint( + "user_id", + "organization_id", + name="_organization_roles_user_organization_uc", + ), + ) + + __repr__ = make_repr("role_name") + + role_name = Column( + Enum(OrganizationRoleType, values_callable=lambda x: [e.value for e in x]), + nullable=False, + ) + user_id = Column( + ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"), nullable=False + ) + organization_id = Column( + ForeignKey("organizations.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + ) + + user = orm.relationship(User, lazy=False) + organization = orm.relationship("Organization", lazy=False) + + +class OrganizationProject(db.Model): + + __tablename__ = "organization_project" + __table_args__ = ( + Index("organization_project_organization_id_idx", "organization_id"), + Index("organization_project_project_id_idx", "project_id"), + UniqueConstraint( + "organization_id", + "project_id", + name="_organization_project_organization_project_uc", + ), + ) + + __repr__ = make_repr("project_id", "organization_id", "is_active") + + is_active = Column(Boolean, nullable=False, default=False) + organization_id = Column( + ForeignKey("organizations.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + ) + project_id = Column( + ForeignKey("projects.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + ) + + organization = orm.relationship("Organization", lazy=False) + project = orm.relationship("Project", lazy=False) + + +class OrganizationType(enum.Enum): + + Community = "Community" + 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 + + +# TODO: Determine if this should also utilize SitemapMixin and TwoFactorRequireable +# class Organization(SitemapMixin, TwoFactorRequireable, HasEvents, db.Model): +class Organization(HasEvents, db.Model): + __tablename__ = "organizations" + __table_args__ = ( + CheckConstraint( + "name ~* '^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$'::text", + name="organizations_valid_name", + ), + ) + + __repr__ = make_repr("name") + + name = Column(Text, nullable=False) + normalized_name = orm.column_property(func.normalize_pep426_name(name)) + display_name = Column(Text, nullable=False) + orgtype = Column( + Enum(OrganizationType, values_callable=lambda x: [e.value for e in x]), + nullable=False, + ) + link_url = Column(URLType, nullable=False) + description = Column(Text, nullable=False) + is_active = Column(Boolean, nullable=False, default=False) + is_approved = Column(Boolean) + created = Column( + DateTime(timezone=False), + nullable=False, + server_default=sql.func.now(), + index=True, + ) + date_approved = Column( + DateTime(timezone=False), + nullable=True, + onupdate=func.now(), + ) + + # TODO: Determine if cascade applies to any of these relationships + users = orm.relationship( + User, secondary=OrganizationRole.__table__, backref="organizations" # type: ignore # noqa + ) + projects = orm.relationship( + "Project", secondary=OrganizationProject.__table__, backref="organizations" # type: ignore # noqa + ) + + # TODO: + # def __acl__(self): + + +class OrganizationNameCatalog(db.Model): + + __tablename__ = "organization_name_catalog" + __table_args__ = ( + Index("organization_name_catalog_normalized_name_idx", "normalized_name"), + Index("organization_name_catalog_organization_id_idx", "organization_id"), + UniqueConstraint( + "normalized_name", + "organization_id", + name="_organization_name_catalog_normalized_name_organization_uc", + ), + ) + + __repr__ = make_repr("normalized_name", "organization_id") + + normalized_name = Column(Text, nullable=False) + organization_id = Column( + ForeignKey("organizations.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + ) + + +class OrganizationInvitationStatus(enum.Enum): + + Pending = "pending" + Expired = "expired" + + +class OrganizationInvitation(db.Model): + + __tablename__ = "organization_invitations" + __table_args__ = ( + Index("organization_invitations_user_id_idx", "user_id"), + UniqueConstraint( + "user_id", + "organization_id", + name="_organization_invitations_user_organization_uc", + ), + ) + + __repr__ = make_repr("invite_status", "user", "organization") + + invite_status = Column( + Enum( + OrganizationInvitationStatus, values_callable=lambda x: [e.value for e in x] + ), + nullable=False, + ) + token = Column(Text, nullable=False) + user_id = Column( + ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + organization_id = Column( + ForeignKey("organizations.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + user = orm.relationship(User, lazy=False) + organization = orm.relationship("Organization", lazy=False) diff --git a/warehouse/organizations/services.py b/warehouse/organizations/services.py new file mode 100644 index 000000000000..394fbcb86981 --- /dev/null +++ b/warehouse/organizations/services.py @@ -0,0 +1,147 @@ +# 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 datetime + +from sqlalchemy.orm.exc import NoResultFound +from zope.interface import implementer + +from warehouse.organizations.interfaces import IOrganizationService +from warehouse.organizations.models import ( + Organization, + OrganizationNameCatalog, + OrganizationRole, +) + + +@implementer(IOrganizationService) +class DatabaseOrganizationService: + def __init__(self, db_session, remote_addr): + self.db = db_session + self.remote_addr = remote_addr + + def get_organization(self, organization_id): + """ + Return the organization object that represents the given organizationid, + or None if there is no organization for that ID. + """ + return self.db.query(Organization).get(organization_id) + + def get_organization_by_name(self, name): + """ + Return the organization object corresponding with the given organization name, + or None if there is no organization with that name. + """ + organization_id = self.find_organizationid(name) + return ( + None if organization_id is None else self.get_organization(organization_id) + ) + + def find_organizationid(self, name): + """ + Find the unique organization identifier for the given normalized name or None + if there is no organization with the given name. + """ + try: + organization = ( + self.db.query(Organization.id) + .filter(Organization.normalized_name == name) + .one() + ) + except NoResultFound: + return + + return organization.id + + def add_organization(self, name, display_name, orgtype, link_url, description): + """ + Accepts a organization object, and attempts to create an organization with those + attributes. + """ + organization = Organization( + name=name, + display_name=display_name, + orgtype=orgtype, + link_url=link_url, + description=description, + ) + self.db.add(organization) + self.db.flush() + + return organization + + def add_catalog_entry(self, name, organization_id): + """ + Adds the organization name to the organization name catalog + """ + organization = self.get_organization(organization_id) + catalog_entry = OrganizationNameCatalog( + normalized_name=name, organization_id=organization.id + ) + + self.db.add(catalog_entry) + self.db.flush() + + return catalog_entry + + def add_organization_role(self, role_name, user_id, organization_id): + """ + Adds the organization role to the specified user and org + """ + organization = self.get_organization(organization_id) + role = OrganizationRole( + role_name=role_name, user_id=user_id, organization_id=organization.id + ) + + self.db.add(role) + self.db.flush() + + return role + + def approve_organization(self, organization_id): + """ + Performs operations necessary to approve an Organization + """ + organization = self.get_organization(organization_id) + organization.is_active = True + organization.is_approved = True + organization.date_approved = datetime.datetime.now() + # self.db.flush() + + return organization + + def decline_organization(self, organization_id): + """ + Performs operations necessary to reject approval of an Organization + """ + organization = self.get_organization(organization_id) + organization.is_approved = False + organization.date_approved = datetime.datetime.now() + # self.db.flush() + + return organization + + def record_event(self, organization_id, *, tag, additional=None): + """ + Creates a new Organization.Event for the given organization with the given + tag, IP address, and additional metadata. + + Returns the event. + """ + organization = self.get_organization(organization_id) + return organization.record_event( + tag=tag, ip_address=self.remote_addr, additional=additional + ) + + +def database_organization_factory(context, request): + return DatabaseOrganizationService(request.db, remote_addr=request.remote_addr) diff --git a/warehouse/routes.py b/warehouse/routes.py index 31aa0eaba377..c3e28a28f529 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -221,6 +221,7 @@ def includeme(config): domain=warehouse, ) config.add_route("manage.account.token", "/manage/account/token/", domain=warehouse) + config.add_route("manage.organizations", "/manage/organizations/", domain=warehouse) config.add_route("manage.projects", "/manage/projects/", domain=warehouse) config.add_route( "manage.project.settings", diff --git a/warehouse/templates/email/admin-new-organization-requested/body.html b/warehouse/templates/email/admin-new-organization-requested/body.html new file mode 100644 index 000000000000..a9be9761f447 --- /dev/null +++ b/warehouse/templates/email/admin-new-organization-requested/body.html @@ -0,0 +1,20 @@ +{# + # 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 "email/_base/body.html" %} + +{% block content %} +

{{ initiator_username }} has requested approval to create a new PyPI organization named "{{ organization_name }}".

+ +

Please follow this link to approve or decline the request.

+{% endblock %} diff --git a/warehouse/templates/email/admin-new-organization-requested/body.txt b/warehouse/templates/email/admin-new-organization-requested/body.txt new file mode 100644 index 000000000000..3f7bacb1fbf2 --- /dev/null +++ b/warehouse/templates/email/admin-new-organization-requested/body.txt @@ -0,0 +1,21 @@ +{# + # 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 "email/_base/body.txt" %} + +{% block content %} +{{ initiator_username }} has requested approval to create a new PyPI organization named "{{ organization_name }}". + +Please follow this link to approve or decline the request: +{{ request.route_url('admin.organization.approve', _query={'organization_name': organization_name}) }} +{% endblock %} diff --git a/warehouse/templates/email/admin-new-organization-requested/subject.txt b/warehouse/templates/email/admin-new-organization-requested/subject.txt new file mode 100644 index 000000000000..bf52ffab575e --- /dev/null +++ b/warehouse/templates/email/admin-new-organization-requested/subject.txt @@ -0,0 +1,17 @@ +{# + # 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 "email/_base/subject.txt" %} + +{% block subject %}{{ initiator_username }} has requested approval to create an organization named "{{ organization_name }}"{% endblock %} diff --git a/warehouse/templates/email/new-organization-requested/body.html b/warehouse/templates/email/new-organization-requested/body.html new file mode 100644 index 000000000000..b09819e63566 --- /dev/null +++ b/warehouse/templates/email/new-organization-requested/body.html @@ -0,0 +1,20 @@ +{# + # 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 "email/_base/body.html" %} + +{% 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 %}

+{% endblock %} diff --git a/warehouse/templates/email/new-organization-requested/body.txt b/warehouse/templates/email/new-organization-requested/body.txt new file mode 100644 index 000000000000..73ac8c2e6b29 --- /dev/null +++ b/warehouse/templates/email/new-organization-requested/body.txt @@ -0,0 +1,20 @@ +{# + # 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 "email/_base/body.txt" %} + +{% 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 %} +{% endblock %} diff --git a/warehouse/templates/email/new-organization-requested/subject.txt b/warehouse/templates/email/new-organization-requested/subject.txt new file mode 100644 index 000000000000..9d44efa1d1f5 --- /dev/null +++ b/warehouse/templates/email/new-organization-requested/subject.txt @@ -0,0 +1,17 @@ +{# + # 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 "email/_base/subject.txt" %} + +{% block subject %}{% trans organization_name=organization_name %}Your request for a new organization named "{{ organization_name }}" has been submitted{% endtrans %}{% endblock %} diff --git a/warehouse/templates/manage/organizations.html b/warehouse/templates/manage/organizations.html new file mode 100644 index 000000000000..1dacd0227bca --- /dev/null +++ b/warehouse/templates/manage/organizations.html @@ -0,0 +1,135 @@ +{# + # 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_base.html" %} + +{% set active_tab = 'organizations' %} + +{% block title %}{% trans %}Your organizations{% endtrans %}{% endblock %} + +{% block main %} +

{{ title }}

+ + {{ form_error_anchor(create_organization_form) }} +
+

{% trans %}Create new organization{% endtrans %}

+ +
+ + {{ form_errors(create_organization_form) }} +
+ + {{ create_organization_form.name(placeholder=gettext("Select an organization account name"), autocapitalize="off", autocomplete="off", spellcheck="false", required="required", class_="form-group__field", **{"aria-describedby":"name-errors"}) }} +
+ {% if create_organization_form.name.errors %} + + {% endif %} +
+

+ {% trans %}This account name will be used in URLs on PyPI.{% endtrans %}
+ {% trans %}For example{% endtrans %}: psf +

+
+ +
+ + {{ create_organization_form.display_name(placeholder=gettext("Name of your business, product, or project"), autocomplete="organization", autocapitalize="off", spellcheck="false", class_="form-group__field", **{"aria-describedby":"display-name-errors"}) }} +
+ {% if create_organization_form.display_name.errors %} + + {% endif %} +
+

+ {% trans %}For example{% endtrans %}: Python Software Foundation +

+
+ +
+ +

+ {{ create_organization_form.link_url(placeholder=gettext("URL for your business, product, or project"), autocomplete="url", autocapitalize="off", spellcheck="false", class_="form-group__field", **{"aria-describedby":"link-url-errors"}) }} +

+ +

+ {% trans %}For example{% endtrans %}: https://www.python.org/psf/ +

+
+ +
+ + {{ create_organization_form.description(placeholder=gettext("Description of your business, product, or project"), autocomplete="off", autocapitalize="off", spellcheck="true", class_="form-group__field", **{"aria-describedby":"description-errors"}) }} +
+ {% if create_organization_form.description.errors %} + + {% endif %} +
+
+ +
+ +

+ {{ create_organization_form.orgtype(class_="form-group__field", **{"aria-describedby":"orgtype-errors"}) }} +

+
+ {{ field_errors(create_organization_form.orgtype) }} +
+

+ {% trans trimmed %} + Companies can create organization accounts as a paid service while community projects are granted complimentary access. + {% endtrans %} +

+
+ + +
+
+{% endblock %}