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 %}
+
+
+
+
+
+
+
+ Name |
+ Account Name |
+ Active |
+ Good Standing |
+
+
+
+
+ {{ project.organization.display_name }} |
+ {{ project.organization.name }} |
+ {{ project.organization.is_active }} |
+ {{ project.organization.good_standing }} |
+
+
+
+
+
+
+{% endif %}
+
@@ -274,28 +302,31 @@
Remove role for {{ role.user.user
+
+
-
-
-
-
- Username |
- Status |
-
-
-
- {% for invitation in project.invitations %}
-
- {{ invitation.user.username }} |
- {{ invitation.invite_status.value }} |
-
- {% endfor %}
-
-
+
+
+
+
+ Username |
+ Status |
+
+
+
+ {% for invitation in project.invitations %}
+
+ {{ invitation.user.username }} |
+ {{ invitation.invite_status.value }} |
+
+ {% endfor %}
+
+
+
-
+
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),
+ )