Skip to content

Commit 0093be2

Browse files
ewdurbindi
andauthored
Organizations: Handle subscription statuses in upload and admin (#18035)
* Organizations: Handle subscription statuses in upload and admin * don't try to report usage for cancelled subscriptions ref: https://python-software-foundation.sentry.io/issues/6572361424/events/f39ea301468a40a49128a386ace3766c/ * Update warehouse/organizations/models.py Co-authored-by: Dustin Ingram <[email protected]> --------- Co-authored-by: Dustin Ingram <[email protected]>
1 parent 9002e57 commit 0093be2

File tree

8 files changed

+325
-29
lines changed

8 files changed

+325
-29
lines changed

tests/unit/forklift/test_legacy.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@
6161
from ...common.db.accounts import EmailFactory, UserFactory
6262
from ...common.db.classifiers import ClassifierFactory
6363
from ...common.db.oidc import GitHubPublisherFactory
64+
from ...common.db.organizations import (
65+
OrganizationFactory,
66+
OrganizationProjectFactory,
67+
OrganizationRoleFactory,
68+
OrganizationStripeSubscriptionFactory,
69+
)
6470
from ...common.db.packaging import (
6571
FileFactory,
6672
ProjectFactory,
@@ -5403,6 +5409,193 @@ def test_upload_fails_when_license_and_license_expression_are_present(
54035409
"for more information."
54045410
)
54055411

5412+
def test_upload_for_organization_owned_project_succeeds(
5413+
self, pyramid_config, db_request, monkeypatch
5414+
):
5415+
organization = OrganizationFactory.create(orgtype="Community")
5416+
user = UserFactory.create(with_verified_primary_email=True)
5417+
OrganizationRoleFactory.create(organization=organization, user=user)
5418+
project = OrganizationProjectFactory.create(organization=organization).project
5419+
version = "1.0.0"
5420+
5421+
filename = (
5422+
f"{project.normalized_name.replace('-', '_')}-{version}-py3-none-any.whl"
5423+
)
5424+
filebody = _get_whl_testdata(
5425+
name=project.normalized_name.replace("-", "_"), version=version
5426+
)
5427+
5428+
@pretend.call_recorder
5429+
def storage_service_store(path, file_path, *, meta):
5430+
with open(file_path, "rb") as fp:
5431+
if file_path.endswith(".metadata"):
5432+
assert fp.read() == b"Fake metadata"
5433+
else:
5434+
assert fp.read() == filebody
5435+
5436+
storage_service = pretend.stub(store=storage_service_store)
5437+
5438+
db_request.find_service = pretend.call_recorder(
5439+
lambda svc, name=None, context=None: {
5440+
IFileStorage: storage_service,
5441+
}.get(svc)
5442+
)
5443+
5444+
monkeypatch.setattr(
5445+
legacy, "_is_valid_dist_file", lambda *a, **kw: (True, None)
5446+
)
5447+
5448+
pyramid_config.testing_securitypolicy(identity=user)
5449+
db_request.user = user
5450+
db_request.user_agent = "warehouse-tests/6.6.6"
5451+
db_request.POST = MultiDict(
5452+
{
5453+
"metadata_version": "1.2",
5454+
"name": project.name,
5455+
"version": "1.0.0",
5456+
"filetype": "bdist_wheel",
5457+
"pyversion": "py3",
5458+
"md5_digest": hashlib.md5(filebody).hexdigest(),
5459+
"content": pretend.stub(
5460+
filename=filename,
5461+
file=io.BytesIO(filebody),
5462+
type="application/zip",
5463+
),
5464+
}
5465+
)
5466+
5467+
resp = legacy.file_upload(db_request)
5468+
5469+
assert resp.status_code == 200
5470+
5471+
def test_upload_for_company_organization_owned_project_fails_without_subscription(
5472+
self, pyramid_config, db_request, monkeypatch
5473+
):
5474+
organization = OrganizationFactory.create(orgtype="Company")
5475+
user = UserFactory.create(with_verified_primary_email=True)
5476+
OrganizationRoleFactory.create(organization=organization, user=user)
5477+
project = OrganizationProjectFactory.create(organization=organization).project
5478+
version = "1.0.0"
5479+
5480+
filename = (
5481+
f"{project.normalized_name.replace('-', '_')}-{version}-py3-none-any.whl"
5482+
)
5483+
filebody = _get_whl_testdata(
5484+
name=project.normalized_name.replace("-", "_"), version=version
5485+
)
5486+
5487+
@pretend.call_recorder
5488+
def storage_service_store(path, file_path, *, meta):
5489+
with open(file_path, "rb") as fp:
5490+
if file_path.endswith(".metadata"):
5491+
assert fp.read() == b"Fake metadata"
5492+
else:
5493+
assert fp.read() == filebody
5494+
5495+
storage_service = pretend.stub(store=storage_service_store)
5496+
5497+
db_request.find_service = pretend.call_recorder(
5498+
lambda svc, name=None, context=None: {
5499+
IFileStorage: storage_service,
5500+
}.get(svc)
5501+
)
5502+
5503+
monkeypatch.setattr(
5504+
legacy, "_is_valid_dist_file", lambda *a, **kw: (True, None)
5505+
)
5506+
5507+
pyramid_config.testing_securitypolicy(identity=user)
5508+
db_request.user = user
5509+
db_request.user_agent = "warehouse-tests/6.6.6"
5510+
db_request.POST = MultiDict(
5511+
{
5512+
"metadata_version": "1.2",
5513+
"name": project.name,
5514+
"version": "1.0.0",
5515+
"filetype": "bdist_wheel",
5516+
"pyversion": "py3",
5517+
"md5_digest": hashlib.md5(filebody).hexdigest(),
5518+
"content": pretend.stub(
5519+
filename=filename,
5520+
file=io.BytesIO(filebody),
5521+
type="application/zip",
5522+
),
5523+
}
5524+
)
5525+
5526+
with pytest.raises(HTTPBadRequest) as excinfo:
5527+
legacy.file_upload(db_request)
5528+
5529+
resp = excinfo.value
5530+
5531+
assert resp.status_code == 400
5532+
assert resp.status == (
5533+
"400 Organization account owning this project is inactive. "
5534+
"This may be due to inactive billing for Company Organizations, "
5535+
"or administrator intervention for Community Organizations. "
5536+
"Please contact [email protected]."
5537+
)
5538+
5539+
def test_upload_for_company_organization_owned_project_suceeds_with_subscription(
5540+
self, pyramid_config, db_request, monkeypatch
5541+
):
5542+
organization = OrganizationFactory.create(orgtype="Company")
5543+
user = UserFactory.create(with_verified_primary_email=True)
5544+
OrganizationRoleFactory.create(organization=organization, user=user)
5545+
OrganizationStripeSubscriptionFactory.create(organization=organization)
5546+
project = OrganizationProjectFactory.create(organization=organization).project
5547+
version = "1.0.0"
5548+
5549+
filename = (
5550+
f"{project.normalized_name.replace('-', '_')}-{version}-py3-none-any.whl"
5551+
)
5552+
filebody = _get_whl_testdata(
5553+
name=project.normalized_name.replace("-", "_"), version=version
5554+
)
5555+
5556+
@pretend.call_recorder
5557+
def storage_service_store(path, file_path, *, meta):
5558+
with open(file_path, "rb") as fp:
5559+
if file_path.endswith(".metadata"):
5560+
assert fp.read() == b"Fake metadata"
5561+
else:
5562+
assert fp.read() == filebody
5563+
5564+
storage_service = pretend.stub(store=storage_service_store)
5565+
5566+
db_request.find_service = pretend.call_recorder(
5567+
lambda svc, name=None, context=None: {
5568+
IFileStorage: storage_service,
5569+
}.get(svc)
5570+
)
5571+
5572+
monkeypatch.setattr(
5573+
legacy, "_is_valid_dist_file", lambda *a, **kw: (True, None)
5574+
)
5575+
5576+
pyramid_config.testing_securitypolicy(identity=user)
5577+
db_request.user = user
5578+
db_request.user_agent = "warehouse-tests/6.6.6"
5579+
db_request.POST = MultiDict(
5580+
{
5581+
"metadata_version": "1.2",
5582+
"name": project.name,
5583+
"version": "1.0.0",
5584+
"filetype": "bdist_wheel",
5585+
"pyversion": "py3",
5586+
"md5_digest": hashlib.md5(filebody).hexdigest(),
5587+
"content": pretend.stub(
5588+
filename=filename,
5589+
file=io.BytesIO(filebody),
5590+
type="application/zip",
5591+
),
5592+
}
5593+
)
5594+
5595+
resp = legacy.file_upload(db_request)
5596+
5597+
assert resp.status_code == 200
5598+
54065599

54075600
def test_submit(pyramid_request):
54085601
resp = legacy.submit(pyramid_request)

tests/unit/organizations/test_tasks.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
update_organziation_subscription_usage_record,
2929
)
3030
from warehouse.subscriptions.interfaces import IBillingService
31+
from warehouse.subscriptions.models import StripeSubscriptionStatus
3132

3233
from ...common.db.organizations import (
3334
OrganizationApplicationFactory,
@@ -156,9 +157,8 @@ def test_delete_declined_organization_applications(self, db_request):
156157

157158
class TestUpdateOrganizationSubscriptionUsage:
158159
def test_update_organization_subscription_usage_record(self, db_request):
159-
# Create an organization with a subscription and members
160+
# Setup an organization with an active subscription
160161
organization = OrganizationFactory.create()
161-
# Add a couple members
162162
owner_user = UserFactory.create()
163163
OrganizationRoleFactory(
164164
organization=organization,
@@ -171,7 +171,6 @@ def test_update_organization_subscription_usage_record(self, db_request):
171171
user=member_user,
172172
role_name=OrganizationRoleType.Member,
173173
)
174-
# Wire up the customer, subscripton, organization, and subscription item
175174
stripe_customer = StripeCustomerFactory.create()
176175
OrganizationStripeCustomerFactory.create(
177176
organization=organization, customer=stripe_customer
@@ -189,6 +188,38 @@ def test_update_organization_subscription_usage_record(self, db_request):
189188
)
190189
StripeSubscriptionItemFactory.create(subscription=subscription)
191190

191+
# Setup an organization with a cancelled subscription
192+
organization = OrganizationFactory.create()
193+
owner_user = UserFactory.create()
194+
OrganizationRoleFactory(
195+
organization=organization,
196+
user=owner_user,
197+
role_name=OrganizationRoleType.Owner,
198+
)
199+
member_user = UserFactory.create()
200+
OrganizationRoleFactory(
201+
organization=organization,
202+
user=member_user,
203+
role_name=OrganizationRoleType.Member,
204+
)
205+
stripe_customer = StripeCustomerFactory.create()
206+
OrganizationStripeCustomerFactory.create(
207+
organization=organization, customer=stripe_customer
208+
)
209+
subscription_product = StripeSubscriptionProductFactory.create()
210+
subscription_price = StripeSubscriptionPriceFactory.create(
211+
subscription_product=subscription_product
212+
)
213+
subscription = StripeSubscriptionFactory.create(
214+
customer=stripe_customer,
215+
subscription_price=subscription_price,
216+
status=StripeSubscriptionStatus.Canceled,
217+
)
218+
OrganizationStripeSubscriptionFactory.create(
219+
organization=organization, subscription=subscription
220+
)
221+
StripeSubscriptionItemFactory.create(subscription=subscription)
222+
192223
create_or_update_usage_record = pretend.call_recorder(
193224
lambda *a, **kw: {
194225
"subscription_item_id": "si_1234",

warehouse/admin/templates/admin/organizations/list.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@
6161
<th>Organization</th>
6262
<th>Description</th>
6363
<th>Type</th>
64-
<th><i class="fa fa-fw"></i> Status</th>
64+
<th>Status</th>
65+
<th>Subscription</th>
66+
<th>Good Standing</th>
6567
</tr>
6668
</thead>
6769

@@ -81,6 +83,11 @@
8183
{% else %}
8284
<td><i class="fa fa-fw fa-times text-red"></i> Inactive</td>
8385
{% endif %}
86+
{% if organization.good_standing %}
87+
<td><i class="fa fa-fw fa-check text-green"></i> Yes</td>
88+
{% else %}
89+
<td><i class="fa fa-fw fa-times text-red"></i> No</td>
90+
{% endif %}
8491
</tr>
8592
{% endfor %}
8693
</tbody>

warehouse/admin/templates/admin/projects/detail.html

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,34 @@ <h3 class="card-title">
193193
</div>
194194
</div> <!-- .card #attributes -->
195195

196+
{% if project.organization %}
197+
<div class="card card-secondary" id="maintainers">
198+
<div class="card-header">Organization</div>
199+
<div class="card-body">
200+
<div class="table-responsive p-0">
201+
<table class="table table-hover table-striped">
202+
<thead>
203+
<tr>
204+
<th>Name</th>
205+
<th>Account Name</th>
206+
<th>Active</th>
207+
<th>Good Standing</th>
208+
</tr>
209+
</thead>
210+
<tbody>
211+
<tr>
212+
<td><a href="{{ request.route_path('admin.organization.detail', organization_id=project.organization.id) }}">{{ project.organization.display_name }}</a></td>
213+
<td>{{ project.organization.name }}</td>
214+
<td>{{ project.organization.is_active }}</td>
215+
<td>{{ project.organization.good_standing }}</td>
216+
</tr>
217+
</tbody>
218+
</table>
219+
</div>
220+
</div>
221+
</div>
222+
{% endif %}
223+
196224
<div class="card card-secondary" id="maintainers">
197225
<div class="card-header">Maintainers</div>
198226
<div class="card-body">
@@ -274,28 +302,31 @@ <h4 class="modal-title" id="exampleModalLabel">Remove role for {{ role.user.user
274302
</table>
275303
</div>
276304
</div>
305+
</div>
277306

307+
<div class="card card-secondary" id="invitations">
278308
<div class="card-header">Invitations</div>
279309
<div class="card-body">
280-
<div class="table-responsive p-0">
281-
<table class="table table-hover table-striped">
282-
<thead>
283-
<tr>
284-
<th>Username</th>
285-
<th>Status</th>
286-
</tr>
287-
</thead>
288-
<tbody>
289-
{% for invitation in project.invitations %}
290-
<tr>
291-
<td><a href="{{ request.route_path('admin.user.detail', username=invitation.user.username) }}">{{ invitation.user.username }}</a></td>
292-
<td>{{ invitation.invite_status.value }}</td>
293-
</tr>
294-
{% endfor %}
295-
</tbody>
296-
</table>
310+
<div class="table-responsive p-0">
311+
<table class="table table-hover table-striped">
312+
<thead>
313+
<tr>
314+
<th>Username</th>
315+
<th>Status</th>
316+
</tr>
317+
</thead>
318+
<tbody>
319+
{% for invitation in project.invitations %}
320+
<tr>
321+
<td><a href="{{ request.route_path('admin.user.detail', username=invitation.user.username) }}">{{ invitation.user.username }}</a></td>
322+
<td>{{ invitation.invite_status.value }}</td>
323+
</tr>
324+
{% endfor %}
325+
</tbody>
326+
</table>
327+
</div>
297328
</div>
298-
</div> <!-- .card #maintainers -->
329+
</div>
299330

300331
<div class="card card-info" id="releases">
301332
<div class="card-header">Releases</div>

warehouse/admin/views/organizations.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,10 @@ def organization_list(request):
6767
except ValueError:
6868
raise HTTPBadRequest("'page' must be an integer.") from None
6969

70-
organizations_query = request.db.query(Organization).order_by(
71-
Organization.normalized_name
70+
organizations_query = (
71+
request.db.query(Organization)
72+
.options(joinedload(Organization.subscriptions))
73+
.order_by(Organization.normalized_name)
7274
)
7375

7476
if q:

0 commit comments

Comments
 (0)