Skip to content

Commit 53439a0

Browse files
ewdurbinclaudemiketheman
authored
Add functionality for editing organizations via admin (#18414)
* Add functionality for editing organizations via admin - Add admin-specific OrganizationForm for editing organization details - Update organization_detail view to handle GET/POST requests - Implement inline editing in organization detail template - Add proper permission controls (AdminOrganizationsWrite) - Add comprehensive test coverage for form validation and view behavior - Support staff and superusers can edit; moderators have read-only access Co-authored-by: Claude <noreply@anthropic.com> * Add tests for organization editing functionality - Add tests for POST handling in organization_detail view - Test successful organization updates - Test validation errors on invalid form data - Update existing tests to include form in response 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix POST method not allowed error for organization edit form - Add require_methods=False to view_config decorators - This follows the same pattern as other admin views like banner edit - Allows the same view function to handle both GET and POST requests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Add Stripe customer updates to admin organization edit form - Add IBillingService import and integration to organization_detail view - Update Stripe customer when organization details are changed - Add tests for Stripe customer synchronization - Update existing tests to include billing service 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Use OrganizationType enum for orgtype field choices - Replace hardcoded choices with dynamic enum values - Follows pattern used in other forms (e.g., OrganizationMembershipSize) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Add clickable link button next to organization URL input - Wrap URL input in Bootstrap input-group with attached button - Button opens organization URL in new tab with security attributes - Preserves ability to edit URL while providing quick access to visit it 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Update warehouse/admin/templates/admin/organizations/detail.html Co-authored-by: Mike Fiedler <miketheman@gmail.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Mike Fiedler <miketheman@gmail.com>
1 parent 428a118 commit 53439a0

File tree

3 files changed

+394
-49
lines changed

3 files changed

+394
-49
lines changed

tests/unit/admin/views/test_organizations.py

Lines changed: 235 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pretend
44
import pytest
55

6-
from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound
6+
from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound, HTTPSeeOther
77
from webob.multidict import MultiDict
88

99
from warehouse.admin.views import organizations as views
@@ -12,12 +12,68 @@
1212
OrganizationApplicationStatus,
1313
OrganizationType,
1414
)
15+
from warehouse.subscriptions.interfaces import IBillingService
1516

1617
from ....common.db.accounts import UserFactory
1718
from ....common.db.organizations import (
1819
OrganizationApplicationFactory,
1920
OrganizationFactory,
21+
OrganizationStripeCustomerFactory,
2022
)
23+
from ....common.db.subscriptions import StripeCustomerFactory
24+
25+
26+
class TestOrganizationForm:
27+
def test_validate_success(self):
28+
form_data = MultiDict(
29+
{
30+
"display_name": "My Organization",
31+
"link_url": "https://example.com",
32+
"description": "A test organization",
33+
"orgtype": "Company",
34+
}
35+
)
36+
form = views.OrganizationForm(formdata=form_data)
37+
assert form.validate(), str(form.errors)
38+
39+
def test_validate_invalid_url(self):
40+
form_data = MultiDict(
41+
{
42+
"display_name": "My Organization",
43+
"link_url": "not-a-url",
44+
"description": "A test organization",
45+
"orgtype": "Company",
46+
}
47+
)
48+
form = views.OrganizationForm(formdata=form_data)
49+
assert not form.validate()
50+
assert "Organization URL must start with http:// or https://" in str(
51+
form.link_url.errors
52+
)
53+
54+
def test_validate_missing_required_fields(self):
55+
form_data = MultiDict({})
56+
form = views.OrganizationForm(formdata=form_data)
57+
assert not form.validate()
58+
assert form.display_name.errors
59+
assert form.link_url.errors
60+
assert form.description.errors
61+
assert form.orgtype.errors
62+
63+
def test_validate_field_too_long(self):
64+
form_data = MultiDict(
65+
{
66+
"display_name": "x" * 101, # Max is 100
67+
"link_url": "https://example.com/" + "x" * 381, # Max is 400
68+
"description": "x" * 401, # Max is 400
69+
"orgtype": "Company",
70+
}
71+
)
72+
form = views.OrganizationForm(formdata=form_data)
73+
assert not form.validate()
74+
assert "100 characters or less" in str(form.display_name.errors)
75+
assert "400 characters or less" in str(form.link_url.errors)
76+
assert "400 characters or less" in str(form.description.errors)
2177

2278

2379
class TestOrganizationList:
@@ -229,17 +285,20 @@ def test_detail(self):
229285
organization_service = pretend.stub(
230286
get_organization=lambda *a, **kw: organization,
231287
)
288+
billing_service = pretend.stub()
232289
request = pretend.stub(
233290
flags=pretend.stub(enabled=lambda *a: False),
234291
find_service=lambda iface, **kw: {
235292
IOrganizationService: organization_service,
293+
IBillingService: billing_service,
236294
}[iface],
237295
matchdict={"organization_id": pretend.stub()},
296+
method="GET",
238297
)
239298

240-
assert views.organization_detail(request) == {
241-
"organization": organization,
242-
}
299+
result = views.organization_detail(request)
300+
assert result["organization"] == organization
301+
assert isinstance(result["form"], views.OrganizationForm)
243302

244303
@pytest.mark.usefixtures("_enable_organizations")
245304
def test_detail_is_approved_true(self):
@@ -260,17 +319,20 @@ def test_detail_is_approved_true(self):
260319
organization_service = pretend.stub(
261320
get_organization=lambda *a, **kw: organization,
262321
)
322+
billing_service = pretend.stub()
263323
request = pretend.stub(
264324
flags=pretend.stub(enabled=lambda *a: False),
265325
find_service=lambda iface, **kw: {
266326
IOrganizationService: organization_service,
327+
IBillingService: billing_service,
267328
}[iface],
268329
matchdict={"organization_id": pretend.stub()},
330+
method="GET",
269331
)
270332

271-
assert views.organization_detail(request) == {
272-
"organization": organization,
273-
}
333+
result = views.organization_detail(request)
334+
assert result["organization"] == organization
335+
assert isinstance(result["form"], views.OrganizationForm)
274336

275337
@pytest.mark.usefixtures("_enable_organizations")
276338
def test_detail_is_approved_false(self):
@@ -291,32 +353,194 @@ def test_detail_is_approved_false(self):
291353
organization_service = pretend.stub(
292354
get_organization=lambda *a, **kw: organization,
293355
)
356+
billing_service = pretend.stub()
294357
request = pretend.stub(
295358
flags=pretend.stub(enabled=lambda *a: False),
296359
find_service=lambda iface, **kw: {
297360
IOrganizationService: organization_service,
361+
IBillingService: billing_service,
298362
}[iface],
299363
matchdict={"organization_id": pretend.stub()},
364+
method="GET",
300365
)
301366

302-
assert views.organization_detail(request) == {
303-
"organization": organization,
304-
}
367+
result = views.organization_detail(request)
368+
assert result["organization"] == organization
369+
assert isinstance(result["form"], views.OrganizationForm)
305370

306371
@pytest.mark.usefixtures("_enable_organizations")
307372
def test_detail_not_found(self):
308373
organization_service = pretend.stub(
309374
get_organization=lambda *a, **kw: None,
310375
)
376+
billing_service = pretend.stub()
311377
request = pretend.stub(
312378
flags=pretend.stub(enabled=lambda *a: False),
313-
find_service=lambda *a, **kw: organization_service,
379+
find_service=lambda iface, **kw: {
380+
IOrganizationService: organization_service,
381+
IBillingService: billing_service,
382+
}[iface],
314383
matchdict={"organization_id": pretend.stub()},
384+
method="GET",
315385
)
316386

317387
with pytest.raises(HTTPNotFound):
318388
views.organization_detail(request)
319389

390+
def test_updates_organization(self, db_request):
391+
organization = OrganizationFactory.create(
392+
display_name="Old Name",
393+
link_url="https://old-url.com",
394+
description="Old description",
395+
orgtype=OrganizationType.Company,
396+
)
397+
organization.customer = None # No Stripe customer
398+
399+
db_request.matchdict = {"organization_id": str(organization.id)}
400+
db_request.method = "POST"
401+
db_request.POST = MultiDict(
402+
{
403+
"display_name": "New Name",
404+
"link_url": "https://new-url.com",
405+
"description": "New description",
406+
"orgtype": "Community",
407+
}
408+
)
409+
db_request.route_path = pretend.call_recorder(
410+
lambda name, **kwargs: f"/admin/organizations/{organization.id}/"
411+
)
412+
db_request.session = pretend.stub(
413+
flash=pretend.call_recorder(lambda *a, **kw: None)
414+
)
415+
416+
organization_service = pretend.stub(
417+
get_organization=pretend.call_recorder(lambda org_id: organization)
418+
)
419+
billing_service = pretend.stub()
420+
421+
db_request.find_service = pretend.call_recorder(
422+
lambda iface, context: {
423+
IOrganizationService: organization_service,
424+
IBillingService: billing_service,
425+
}.get(iface)
426+
)
427+
428+
result = views.organization_detail(db_request)
429+
430+
assert isinstance(result, HTTPSeeOther)
431+
assert result.location == f"/admin/organizations/{organization.id}/"
432+
assert organization.display_name == "New Name"
433+
assert organization.link_url == "https://new-url.com"
434+
assert organization.description == "New description"
435+
assert organization.orgtype == OrganizationType.Community
436+
assert db_request.session.flash.calls == [
437+
pretend.call(
438+
f"Organization {organization.name!r} updated successfully",
439+
queue="success",
440+
)
441+
]
442+
443+
def test_updates_organization_with_stripe_customer(self, db_request):
444+
organization = OrganizationFactory.create(
445+
name="acme",
446+
display_name="Old Name",
447+
link_url="https://old-url.com",
448+
description="Old description",
449+
orgtype=OrganizationType.Company,
450+
)
451+
stripe_customer = StripeCustomerFactory.create(customer_id="cus_123456")
452+
OrganizationStripeCustomerFactory.create(
453+
organization=organization, customer=stripe_customer
454+
)
455+
456+
db_request.matchdict = {"organization_id": str(organization.id)}
457+
db_request.method = "POST"
458+
db_request.POST = MultiDict(
459+
{
460+
"display_name": "New Name",
461+
"link_url": "https://new-url.com",
462+
"description": "New description",
463+
"orgtype": "Community",
464+
}
465+
)
466+
db_request.route_path = pretend.call_recorder(
467+
lambda name, **kwargs: f"/admin/organizations/{organization.id}/"
468+
)
469+
db_request.session = pretend.stub(
470+
flash=pretend.call_recorder(lambda *a, **kw: None)
471+
)
472+
db_request.registry = pretend.stub(settings={"site.name": "TestPyPI"})
473+
474+
organization_service = pretend.stub(
475+
get_organization=pretend.call_recorder(lambda org_id: organization)
476+
)
477+
billing_service = pretend.stub(
478+
update_customer=pretend.call_recorder(lambda *a, **kw: None)
479+
)
480+
481+
db_request.find_service = pretend.call_recorder(
482+
lambda iface, context: {
483+
IOrganizationService: organization_service,
484+
IBillingService: billing_service,
485+
}.get(iface)
486+
)
487+
488+
result = views.organization_detail(db_request)
489+
490+
assert isinstance(result, HTTPSeeOther)
491+
assert result.location == f"/admin/organizations/{organization.id}/"
492+
assert organization.display_name == "New Name"
493+
assert organization.link_url == "https://new-url.com"
494+
assert organization.description == "New description"
495+
assert organization.orgtype == OrganizationType.Community
496+
assert billing_service.update_customer.calls == [
497+
pretend.call(
498+
"cus_123456",
499+
"TestPyPI Organization - New Name (acme)",
500+
"New description",
501+
)
502+
]
503+
assert db_request.session.flash.calls == [
504+
pretend.call(
505+
f"Organization {organization.name!r} updated successfully",
506+
queue="success",
507+
)
508+
]
509+
510+
def test_does_not_update_with_invalid_form(self, db_request):
511+
organization = OrganizationFactory.create()
512+
513+
db_request.matchdict = {"organization_id": str(organization.id)}
514+
db_request.method = "POST"
515+
db_request.POST = MultiDict(
516+
{
517+
"display_name": "", # Required field
518+
"link_url": "invalid-url", # Invalid URL
519+
"description": "Some description",
520+
"orgtype": "Company",
521+
}
522+
)
523+
524+
organization_service = pretend.stub(
525+
get_organization=pretend.call_recorder(lambda org_id: organization)
526+
)
527+
billing_service = pretend.stub()
528+
529+
db_request.find_service = pretend.call_recorder(
530+
lambda iface, context: {
531+
IOrganizationService: organization_service,
532+
IBillingService: billing_service,
533+
}.get(iface)
534+
)
535+
536+
result = views.organization_detail(db_request)
537+
538+
assert result["organization"] == organization
539+
assert isinstance(result["form"], views.OrganizationForm)
540+
assert result["form"].errors
541+
assert "display_name" in result["form"].errors
542+
assert "link_url" in result["form"].errors
543+
320544

321545
class TestOrganizationActions:
322546
@pytest.mark.usefixtures("_enable_organizations")

0 commit comments

Comments
 (0)