diff --git a/tests/unit/forklift/test_legacy.py b/tests/unit/forklift/test_legacy.py index 9717f6b89532..c0ffcd7d2880 100644 --- a/tests/unit/forklift/test_legacy.py +++ b/tests/unit/forklift/test_legacy.py @@ -61,6 +61,12 @@ from ...common.db.accounts import EmailFactory, UserFactory from ...common.db.classifiers import ClassifierFactory from ...common.db.oidc import GitHubPublisherFactory +from ...common.db.organizations import ( + OrganizationFactory, + OrganizationProjectFactory, + OrganizationRoleFactory, + OrganizationStripeSubscriptionFactory, +) from ...common.db.packaging import ( FileFactory, ProjectFactory, @@ -5403,6 +5409,193 @@ def test_upload_fails_when_license_and_license_expression_are_present( "for more information." ) + def test_upload_for_organization_owned_project_succeeds( + self, pyramid_config, db_request, monkeypatch + ): + organization = OrganizationFactory.create(orgtype="Community") + user = UserFactory.create(with_verified_primary_email=True) + OrganizationRoleFactory.create(organization=organization, user=user) + project = OrganizationProjectFactory.create(organization=organization).project + version = "1.0.0" + + filename = ( + f"{project.normalized_name.replace('-', '_')}-{version}-py3-none-any.whl" + ) + filebody = _get_whl_testdata( + name=project.normalized_name.replace("-", "_"), version=version + ) + + @pretend.call_recorder + def storage_service_store(path, file_path, *, meta): + with open(file_path, "rb") as fp: + if file_path.endswith(".metadata"): + assert fp.read() == b"Fake metadata" + else: + assert fp.read() == filebody + + storage_service = pretend.stub(store=storage_service_store) + + db_request.find_service = pretend.call_recorder( + lambda svc, name=None, context=None: { + IFileStorage: storage_service, + }.get(svc) + ) + + monkeypatch.setattr( + legacy, "_is_valid_dist_file", lambda *a, **kw: (True, None) + ) + + pyramid_config.testing_securitypolicy(identity=user) + db_request.user = user + db_request.user_agent = "warehouse-tests/6.6.6" + db_request.POST = MultiDict( + { + "metadata_version": "1.2", + "name": project.name, + "version": "1.0.0", + "filetype": "bdist_wheel", + "pyversion": "py3", + "md5_digest": hashlib.md5(filebody).hexdigest(), + "content": pretend.stub( + filename=filename, + file=io.BytesIO(filebody), + type="application/zip", + ), + } + ) + + resp = legacy.file_upload(db_request) + + assert resp.status_code == 200 + + def test_upload_for_company_organization_owned_project_fails_without_subscription( + self, pyramid_config, db_request, monkeypatch + ): + organization = OrganizationFactory.create(orgtype="Company") + user = UserFactory.create(with_verified_primary_email=True) + OrganizationRoleFactory.create(organization=organization, user=user) + project = OrganizationProjectFactory.create(organization=organization).project + version = "1.0.0" + + filename = ( + f"{project.normalized_name.replace('-', '_')}-{version}-py3-none-any.whl" + ) + filebody = _get_whl_testdata( + name=project.normalized_name.replace("-", "_"), version=version + ) + + @pretend.call_recorder + def storage_service_store(path, file_path, *, meta): + with open(file_path, "rb") as fp: + if file_path.endswith(".metadata"): + assert fp.read() == b"Fake metadata" + else: + assert fp.read() == filebody + + storage_service = pretend.stub(store=storage_service_store) + + db_request.find_service = pretend.call_recorder( + lambda svc, name=None, context=None: { + IFileStorage: storage_service, + }.get(svc) + ) + + monkeypatch.setattr( + legacy, "_is_valid_dist_file", lambda *a, **kw: (True, None) + ) + + pyramid_config.testing_securitypolicy(identity=user) + db_request.user = user + db_request.user_agent = "warehouse-tests/6.6.6" + db_request.POST = MultiDict( + { + "metadata_version": "1.2", + "name": project.name, + "version": "1.0.0", + "filetype": "bdist_wheel", + "pyversion": "py3", + "md5_digest": hashlib.md5(filebody).hexdigest(), + "content": pretend.stub( + filename=filename, + file=io.BytesIO(filebody), + type="application/zip", + ), + } + ) + + with pytest.raises(HTTPBadRequest) as excinfo: + legacy.file_upload(db_request) + + resp = excinfo.value + + assert resp.status_code == 400 + assert resp.status == ( + "400 Organization account owning this project is inactive. " + "This may be due to inactive billing for Company Organizations, " + "or administrator intervention for Community Organizations. " + "Please contact support+orgs@pypi.org." + ) + + def test_upload_for_company_organization_owned_project_suceeds_with_subscription( + self, pyramid_config, db_request, monkeypatch + ): + organization = OrganizationFactory.create(orgtype="Company") + user = UserFactory.create(with_verified_primary_email=True) + OrganizationRoleFactory.create(organization=organization, user=user) + OrganizationStripeSubscriptionFactory.create(organization=organization) + project = OrganizationProjectFactory.create(organization=organization).project + version = "1.0.0" + + filename = ( + f"{project.normalized_name.replace('-', '_')}-{version}-py3-none-any.whl" + ) + filebody = _get_whl_testdata( + name=project.normalized_name.replace("-", "_"), version=version + ) + + @pretend.call_recorder + def storage_service_store(path, file_path, *, meta): + with open(file_path, "rb") as fp: + if file_path.endswith(".metadata"): + assert fp.read() == b"Fake metadata" + else: + assert fp.read() == filebody + + storage_service = pretend.stub(store=storage_service_store) + + db_request.find_service = pretend.call_recorder( + lambda svc, name=None, context=None: { + IFileStorage: storage_service, + }.get(svc) + ) + + monkeypatch.setattr( + legacy, "_is_valid_dist_file", lambda *a, **kw: (True, None) + ) + + pyramid_config.testing_securitypolicy(identity=user) + db_request.user = user + db_request.user_agent = "warehouse-tests/6.6.6" + db_request.POST = MultiDict( + { + "metadata_version": "1.2", + "name": project.name, + "version": "1.0.0", + "filetype": "bdist_wheel", + "pyversion": "py3", + "md5_digest": hashlib.md5(filebody).hexdigest(), + "content": pretend.stub( + filename=filename, + file=io.BytesIO(filebody), + type="application/zip", + ), + } + ) + + resp = legacy.file_upload(db_request) + + assert resp.status_code == 200 + def test_submit(pyramid_request): resp = legacy.submit(pyramid_request) diff --git a/tests/unit/organizations/test_tasks.py b/tests/unit/organizations/test_tasks.py index e7628a2186df..e8fc8d08b197 100644 --- a/tests/unit/organizations/test_tasks.py +++ b/tests/unit/organizations/test_tasks.py @@ -28,6 +28,7 @@ update_organziation_subscription_usage_record, ) from warehouse.subscriptions.interfaces import IBillingService +from warehouse.subscriptions.models import StripeSubscriptionStatus from ...common.db.organizations import ( OrganizationApplicationFactory, @@ -156,9 +157,8 @@ def test_delete_declined_organization_applications(self, db_request): class TestUpdateOrganizationSubscriptionUsage: def test_update_organization_subscription_usage_record(self, db_request): - # Create an organization with a subscription and members + # Setup an organization with an active subscription organization = OrganizationFactory.create() - # Add a couple members owner_user = UserFactory.create() OrganizationRoleFactory( organization=organization, @@ -171,7 +171,6 @@ def test_update_organization_subscription_usage_record(self, db_request): user=member_user, role_name=OrganizationRoleType.Member, ) - # Wire up the customer, subscripton, organization, and subscription item stripe_customer = StripeCustomerFactory.create() OrganizationStripeCustomerFactory.create( organization=organization, customer=stripe_customer @@ -189,6 +188,38 @@ def test_update_organization_subscription_usage_record(self, db_request): ) StripeSubscriptionItemFactory.create(subscription=subscription) + # Setup an organization with a cancelled subscription + organization = OrganizationFactory.create() + owner_user = UserFactory.create() + OrganizationRoleFactory( + organization=organization, + user=owner_user, + role_name=OrganizationRoleType.Owner, + ) + member_user = UserFactory.create() + OrganizationRoleFactory( + organization=organization, + user=member_user, + role_name=OrganizationRoleType.Member, + ) + stripe_customer = StripeCustomerFactory.create() + OrganizationStripeCustomerFactory.create( + organization=organization, customer=stripe_customer + ) + subscription_product = StripeSubscriptionProductFactory.create() + subscription_price = StripeSubscriptionPriceFactory.create( + subscription_product=subscription_product + ) + subscription = StripeSubscriptionFactory.create( + customer=stripe_customer, + subscription_price=subscription_price, + status=StripeSubscriptionStatus.Canceled, + ) + OrganizationStripeSubscriptionFactory.create( + organization=organization, subscription=subscription + ) + StripeSubscriptionItemFactory.create(subscription=subscription) + create_or_update_usage_record = pretend.call_recorder( lambda *a, **kw: { "subscription_item_id": "si_1234", diff --git a/warehouse/admin/templates/admin/organizations/list.html b/warehouse/admin/templates/admin/organizations/list.html index 1bba21706013..a08bcf6d3bbc 100644 --- a/warehouse/admin/templates/admin/organizations/list.html +++ b/warehouse/admin/templates/admin/organizations/list.html @@ -61,7 +61,9 @@ Organization Description Type - Status + Status + Subscription + Good Standing @@ -81,6 +83,11 @@ {% else %} Inactive {% endif %} + {% if organization.good_standing %} + Yes + {% else %} + No + {% endif %} {% endfor %} diff --git a/warehouse/admin/templates/admin/projects/detail.html b/warehouse/admin/templates/admin/projects/detail.html index 34c3f5ec803a..9f7301afc1c2 100644 --- a/warehouse/admin/templates/admin/projects/detail.html +++ b/warehouse/admin/templates/admin/projects/detail.html @@ -193,6 +193,34 @@

+{% if project.organization %} +
+
Organization
+
+
+ + + + + + + + + + + + + + + + + +
NameAccount NameActiveGood Standing
{{ project.organization.display_name }}{{ project.organization.name }}{{ project.organization.is_active }}{{ project.organization.good_standing }}
+
+
+
+{% endif %} +
Maintainers
@@ -274,28 +302,31 @@
+ +
Invitations
-
- - - - - - - - - {% for invitation in project.invitations %} - - - - - {% endfor %} - -
UsernameStatus
{{ invitation.user.username }}{{ invitation.invite_status.value }}
+
+ + + + + + + + + {% for invitation in project.invitations %} + + + + + {% endfor %} + +
UsernameStatus
{{ invitation.user.username }}{{ invitation.invite_status.value }}
+
-
+
Releases
diff --git a/warehouse/admin/views/organizations.py b/warehouse/admin/views/organizations.py index a0e8de9e1eca..cf306717f5f3 100644 --- a/warehouse/admin/views/organizations.py +++ b/warehouse/admin/views/organizations.py @@ -67,8 +67,10 @@ def organization_list(request): except ValueError: raise HTTPBadRequest("'page' must be an integer.") from None - organizations_query = request.db.query(Organization).order_by( - Organization.normalized_name + organizations_query = ( + request.db.query(Organization) + .options(joinedload(Organization.subscriptions)) + .order_by(Organization.normalized_name) ) if q: diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py index 782a91a6ef77..7bc37f738eef 100644 --- a/warehouse/forklift/legacy.py +++ b/warehouse/forklift/legacy.py @@ -742,6 +742,22 @@ def file_upload(request): ) raise _exc_with_message(HTTPForbidden, msg) + # If organization owned project, check if the organization is active. + # Inactive organizations cannot upload new releases to their projects. + if project.organization and not project.organization.good_standing: + request.metrics.increment( + "warehouse.upload.failed", tags=["reason:org-not-active"] + ) + raise _exc_with_message( + HTTPBadRequest, + ( + "Organization account owning this project is inactive. " + "This may be due to inactive billing for Company Organizations, " + "or administrator intervention for Community Organizations. " + "Please contact support+orgs@pypi.org." + ), + ) + # If this is a user identity (i.e: API token) but there exists # a trusted publisher for this project, send an email warning that an # API token was used to upload a project where Trusted Publishing is configured. diff --git a/warehouse/organizations/models.py b/warehouse/organizations/models.py index 179dd325e7f3..7e7f93e586ee 100644 --- a/warehouse/organizations/models.py +++ b/warehouse/organizations/models.py @@ -371,6 +371,18 @@ def record_event(self, *, tag, request: Request = None, additional=None): additional={"organization_name": self.name, **additional}, ) + @property + def good_standing(self): + return ( + # Organization is active. + self.is_active + # Organization has active subscription if it is a Company. + and not ( + self.orgtype == OrganizationType.Company + and self.active_subscription is None + ) + ) + def __acl__(self): session = orm_session_from_obj(self) diff --git a/warehouse/organizations/tasks.py b/warehouse/organizations/tasks.py index 0d615e1eb868..67c23f973101 100644 --- a/warehouse/organizations/tasks.py +++ b/warehouse/organizations/tasks.py @@ -23,6 +23,7 @@ OrganizationStripeSubscription, ) from warehouse.subscriptions.interfaces import IBillingService +from warehouse.subscriptions.models import StripeSubscriptionStatus CLEANUP_AFTER = datetime.timedelta(days=30) @@ -82,8 +83,11 @@ def update_organziation_subscription_usage_record(request): # Call the Billing API to update the usage record of this subscription item for org_subscription in organization_subscriptions: - billing_service = request.find_service(IBillingService, context=None) - billing_service.create_or_update_usage_record( - org_subscription.subscription.subscription_item.subscription_item_id, - len(org_subscription.organization.users), - ) + if org_subscription.subscription.status not in ( + StripeSubscriptionStatus.Canceled, + ): + billing_service = request.find_service(IBillingService, context=None) + billing_service.create_or_update_usage_record( + org_subscription.subscription.subscription_item.subscription_item_id, + len(org_subscription.organization.users), + )