Skip to content

Commit 072fd89

Browse files
authored
More org review tweaks (#17890)
* add a lil visual for applications with outstanding/fulfilled info requests * org request more info template: remove leading www. from parsed domain * handle borked urls * Allow admins to edit applications on behalf of users
1 parent a338fa7 commit 072fd89

File tree

5 files changed

+179
-23
lines changed

5 files changed

+179
-23
lines changed

tests/unit/admin/views/test_organizations.py

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import pytest
1515

1616
from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound
17+
from webob.multidict import MultiDict
1718

1819
from warehouse.admin.views import organizations as views
1920
from warehouse.organizations.interfaces import IOrganizationService
@@ -545,11 +546,63 @@ def test_detail(self, db_request):
545546
db_request.matchdict["organization_application_id"] = (
546547
organization_application.id
547548
)
548-
assert views.organization_application_detail(db_request) == {
549-
"user": organization_application.submitted_by,
550-
"conflicting_applications": [],
551-
"organization_application": organization_application,
552-
}
549+
result = views.organization_application_detail(db_request)
550+
assert result["user"] == organization_application.submitted_by
551+
assert result["form"].name.data == organization_application.name
552+
assert result["conflicting_applications"] == []
553+
assert result["organization_application"] == organization_application
554+
555+
@pytest.mark.usefixtures("_enable_organizations")
556+
def test_detail_edit(self, db_request):
557+
organization_application = OrganizationApplicationFactory.create()
558+
db_request.matchdict["organization_application_id"] = (
559+
organization_application.id
560+
)
561+
562+
new_org_name = f"New-Org-Name-{organization_application.name}"
563+
db_request.method = "POST"
564+
db_request.POST["name"] = new_org_name
565+
db_request.POST["description"] = organization_application.description
566+
db_request.POST["display_name"] = organization_application.display_name
567+
db_request.POST["link_url"] = organization_application.link_url
568+
db_request.POST["orgtype"] = organization_application.orgtype
569+
db_request.POST = MultiDict(db_request.POST)
570+
571+
db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None)
572+
db_request.current_route_path = lambda *a, **kw: "/the/url/"
573+
574+
result = views.organization_application_detail(db_request)
575+
576+
assert result.status_code == 303
577+
assert result.location == "/the/url/"
578+
assert db_request.session.flash.calls == [
579+
pretend.call(
580+
f"Application for {organization_application.name!r} updated",
581+
queue="success",
582+
)
583+
]
584+
585+
assert organization_application.name == new_org_name
586+
587+
@pytest.mark.usefixtures("_enable_organizations")
588+
def test_detail_edit_invalid(self, db_request):
589+
existing_organization = OrganizationFactory.create()
590+
organization_application = OrganizationApplicationFactory.create()
591+
592+
db_request.matchdict["organization_application_id"] = (
593+
organization_application.id
594+
)
595+
db_request.method = "POST"
596+
db_request.POST["name"] = existing_organization.name
597+
db_request.POST = MultiDict(db_request.POST)
598+
599+
result = views.organization_application_detail(db_request)
600+
601+
assert result["user"] == organization_application.submitted_by
602+
assert result["form"].name.data == existing_organization.name
603+
assert result["form"].name.errors != []
604+
assert result["conflicting_applications"] == []
605+
assert result["organization_application"] == organization_application
553606

554607
@pytest.mark.usefixtures("_enable_organizations")
555608
def test_detail_is_approved_true(self, db_request):
@@ -559,11 +612,11 @@ def test_detail_is_approved_true(self, db_request):
559612
db_request.matchdict["organization_application_id"] = (
560613
organization_application.id
561614
)
562-
assert views.organization_application_detail(db_request) == {
563-
"user": organization_application.submitted_by,
564-
"conflicting_applications": [],
565-
"organization_application": organization_application,
566-
}
615+
result = views.organization_application_detail(db_request)
616+
assert result["user"] == organization_application.submitted_by
617+
assert result["form"].name.data == organization_application.name
618+
assert result["conflicting_applications"] == []
619+
assert result["organization_application"] == organization_application
567620

568621
@pytest.mark.usefixtures("_enable_organizations")
569622
def test_detail_is_approved_false(self, db_request):
@@ -573,11 +626,11 @@ def test_detail_is_approved_false(self, db_request):
573626
db_request.matchdict["organization_application_id"] = (
574627
organization_application.id
575628
)
576-
assert views.organization_application_detail(db_request) == {
577-
"user": organization_application.submitted_by,
578-
"conflicting_applications": [],
579-
"organization_application": organization_application,
580-
}
629+
result = views.organization_application_detail(db_request)
630+
assert result["user"] == organization_application.submitted_by
631+
assert result["form"].name.data == organization_application.name
632+
assert result["conflicting_applications"] == []
633+
assert result["organization_application"] == organization_application
581634

582635
@pytest.mark.usefixtures("_enable_organizations")
583636
@pytest.mark.parametrize(
@@ -601,11 +654,11 @@ def test_detail_conflicting_applications(self, db_request, name, conflicts):
601654
db_request.matchdict["organization_application_id"] = (
602655
organization_application.id
603656
)
604-
assert views.organization_application_detail(db_request) == {
605-
"user": organization_application.submitted_by,
606-
"conflicting_applications": conflicting_applications,
607-
"organization_application": organization_application,
608-
}
657+
result = views.organization_application_detail(db_request)
658+
assert result["user"] == organization_application.submitted_by
659+
assert result["form"].name.data == organization_application.name
660+
assert result["conflicting_applications"] == conflicting_applications
661+
assert result["organization_application"] == organization_application
609662

610663
@pytest.mark.usefixtures("_enable_organizations")
611664
def test_detail_not_found(self):

warehouse/admin/static/js/warehouse.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,13 @@ if (requestMoreInformationEmailTemplateButton !== "undefined") {
240240
});
241241
}
242242

243+
let editModalForm = document.getElementById("editModalForm");
244+
if (editModalForm !== "undefined") {
245+
if (editModalForm.classList.contains("edit-form-errors")) {
246+
document.getElementById("editModalButton").click();
247+
}
248+
}
249+
243250
// Link Checking
244251
const links = document.querySelectorAll("a[data-check-link-url]");
245252
links.forEach(function(link){

warehouse/admin/templates/admin/organization_applications/detail.html

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,31 @@
2020
<li class="breadcrumb-item active">{{ organization_application.name }}</li>
2121
{% endblock %}
2222

23+
{% macro render_field(label, field, input_id, placeholder=None, class=None) %}
24+
<div class="form-group{% if field.errors %} has-error{% endif %}">
25+
<label>
26+
{{ label }}
27+
</label>
28+
{{ field(id=input_id, class=class, placeholder=placeholder) }}
29+
30+
{% if field.errors %}
31+
<div class="help-block">
32+
{% for error in field.errors %}
33+
<div class="text-red">{{ error }}</div>
34+
{% endfor %}
35+
</div>
36+
{% endif %}
37+
</div>
38+
{% endmacro %}
39+
2340
{% block content %}
2441
<div class="row">
2542
<div class="col-md-3">
2643
<div class="card card-primary">
2744
<div class="card-body card-widget widget-user-1">
2845
<div class="widget-user-header">
2946
<h3 class="widget-user-username text-center">{{ organization_application.name }}</h3>
47+
<h6 class="widget-user-username text-center">{{ organization_application.display_name }}</h6>
3048
</div>
3149
</div>
3250
<div class="card-body">
@@ -41,6 +59,19 @@ <h3 class="widget-user-username text-center">{{ organization_application.name }}
4159
<h2 class="card-title">Actions</h2>
4260
</div>
4361
<div class="card-body">
62+
<div class="form-group">
63+
<button
64+
type="button"
65+
class="btn btn-warning btn-block"
66+
id="editModalButton"
67+
data-toggle="modal"
68+
data-target="#editModal" {{ 'disabled' if not request.has_permission(Permissions.AdminOrganizationsWrite) or organization_application.status.value in ['approved', 'declined'] }}
69+
title="Edit organization request"
70+
>
71+
<i class="fa-solid fa-pen-to-square"></i> Edit
72+
</button>
73+
</div>
74+
4475
<div class="form-group">
4576
<button
4677
type="button"
@@ -83,6 +114,37 @@ <h2 class="card-title">Actions</h2>
83114
</button>
84115

85116
{% if user %}
117+
<div class="modal fade" id="editModal" tabindex="-1" role="dialog">
118+
<form method="POST" action="{{ request.current_route_path() }}" id="editModalForm" class="{% if form.errors %}edit-form-errors{% endif %}">
119+
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
120+
<div class="modal-dialog" role="document">
121+
<div class="modal-content">
122+
<div class="modal-header">
123+
<h4 class="modal-title" id="editModalLabel">
124+
Edit {{ organization_application.name }}?
125+
</h4>
126+
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
127+
<span aria-hidden="true">&times;</span>
128+
</button>
129+
</div>
130+
<div class="modal-body">
131+
{{ render_field("Organization Display Name", form.display_name, "organization-display-name", class="form-control") }}
132+
{{ render_field("Organization Name", form.name, "organization-name", class="form-control") }}
133+
{{ render_field("Organization URL", form.link_url, "organization-link-url", class="form-control") }}
134+
{{ render_field("Organization Description", form.description, "organization-description", class="form-control") }}
135+
{{ render_field("Organization Type", form.orgtype, "organization-type", class="form-control") }}
136+
</div>
137+
<div class="modal-footer justify-content-between">
138+
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
139+
<button type="submit" class="btn btn-primary">
140+
<i class="icon fa fa-edit"></i> Approve organization request
141+
</button>
142+
</div>
143+
</div>
144+
</div>
145+
</form>
146+
</div>
147+
86148
<div class="modal fade" id="approveModal" tabindex="-1" role="dialog">
87149
<form method="POST" action="{{ request.route_path('admin.organization_application.approve', organization_application_id=organization_application.id) }}">
88150
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
@@ -169,7 +231,7 @@ <h4 class="modal-title" id="deferModalLabel">
169231
<div class="hidden" id="requestMoreInformationEmailTemplate">
170232
Hello {{ organization_application.submitted_by.username }}!
171233
We'd be happy to approve this organization request,
172-
but ask that you first add an @{% with parsed = organization_application.link_url|urlparse %}{{ parsed.host }}{% endwith %}
234+
but ask that you first add an @{% with parsed = organization_application.link_url|urlparse %}{% if parsed.host %}{{ parsed.host.lstrip("www.") }}{% else %}NONE{% endif %}{% endwith %}
173235
email address to your PyPI account,
174236
complete the verification,
175237
and respond using the linked form.
@@ -277,10 +339,12 @@ <h4 class="modal-title" id="declineModalLabel">
277339
</div>
278340
</div>
279341

342+
{% set information_requests = organization_application.information_requests %}
343+
{% set outstanding_information_requests = organization_application.information_requests|selectattr("additional", "defined")|map(attribute="additional")|selectattr("response", "undefined")|list %}
280344
<div class="col-md-9">
281345
<div class="card">
282346
<div class="card-header with-border">
283-
<h3 class="card-title">Organization Request</h3>
347+
<h3 class="card-title">Organization Request{% if information_requests %}{% if outstanding_information_requests %} <i class="fa-solid fa-envelope text-info"></i>{% else %} <i class="fa-solid fa-envelope-open text-green"></i><i class="fa-solid fa-reply text-green"></i>{% endif %}{% endif %}</h3>
284348
</div>
285349
<div class="card-body">
286350
<div class="form-horizontal">
@@ -374,7 +438,7 @@ <h3 class="card-title">Organization Request</h3>
374438
<h3 class="card-title">Information Requests</h3>
375439
</div>
376440
<div class="card-body">
377-
{% for information_request in organization_application.information_requests %}
441+
{% for information_request in information_requests %}
378442
<div class="card {% if information_request.additional.response %}card-neutral{% else %}card-warning{% endif %} card-outline direct-chat direct-chat-primary"</div>
379443
<div class="card-body">
380444
<div class="direct-chat-messages unset-height">

warehouse/admin/templates/admin/organization_applications/list.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,11 @@
6969

7070
<tbody>
7171
{% for organization_application in organization_applications %}
72+
{% set information_requests = organization_application.information_requests %}
73+
{% set outstanding_information_requests = organization_application.information_requests|selectattr("additional", "defined")|map(attribute="additional")|selectattr("response", "undefined")|list %}
7274
<tr>
7375
<td>
76+
{% if information_requests %}{% if outstanding_information_requests %} <i class="fa-solid fa-envelope text-info"></i>{% else %} <i class="fa-solid fa-envelope-open text-green"></i><i class="fa-solid fa-reply text-green"></i>{% endif %}{% endif %}
7477
<a href="{{ request.route_path('admin.organization_application.detail', organization_application_id=organization_application.id) }}">{{ organization_application.name }}</a>
7578
</td>
7679
<td>

warehouse/admin/views/organizations.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound, HTTPSeeOther
1717
from pyramid.view import view_config
1818
from sqlalchemy import or_
19+
from sqlalchemy.orm import joinedload
1920

2021
from warehouse.accounts.interfaces import IUserService
2122
from warehouse.authnz import Permissions
23+
from warehouse.manage.forms import OrganizationNameMixin, SaveOrganizationForm
2224
from warehouse.organizations.interfaces import IOrganizationService
2325
from warehouse.organizations.models import (
2426
Organization,
@@ -265,13 +267,24 @@ def organization_applications_list(request):
265267
organization_applications_query.filter(filter_or_subfilters)
266268
)
267269

270+
organization_applications_query = organization_applications_query.options(
271+
joinedload(OrganizationApplication.observations)
272+
)
273+
268274
return {
269275
"organization_applications": organization_applications_query.all(),
270276
"query": q,
271277
"terms": terms,
272278
}
273279

274280

281+
class OrganizationApplicationForm(OrganizationNameMixin, SaveOrganizationForm):
282+
def __init__(self, *args, organization_service, user, **kwargs):
283+
super().__init__(*args, **kwargs)
284+
self.organization_service = organization_service
285+
self.user = user
286+
287+
275288
@view_config(
276289
route_name="admin.organization_application.detail",
277290
require_methods=False,
@@ -292,6 +305,21 @@ def organization_application_detail(request):
292305
if organization_application is None:
293306
raise HTTPNotFound
294307

308+
form = OrganizationApplicationForm(
309+
request.POST if request.method == "POST" else None,
310+
organization_application,
311+
organization_service=organization_service,
312+
user=request.user,
313+
)
314+
315+
if request.method == "POST" and form.validate():
316+
form.populate_obj(organization_application)
317+
request.session.flash(
318+
f"Application for {organization_application.name!r} updated",
319+
queue="success",
320+
)
321+
return HTTPSeeOther(location=request.current_route_path())
322+
295323
conflicting_applications = (
296324
request.db.query(OrganizationApplication)
297325
.filter(
@@ -307,6 +335,7 @@ def organization_application_detail(request):
307335

308336
return {
309337
"organization_application": organization_application,
338+
"form": form,
310339
"conflicting_applications": conflicting_applications,
311340
"user": user,
312341
}

0 commit comments

Comments
 (0)