Skip to content

Commit a7b4de7

Browse files
Add bulk update buttons (#672)
1 parent 94448dc commit a7b4de7

File tree

8 files changed

+226
-11
lines changed

8 files changed

+226
-11
lines changed

admin-js/src/App.js

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
Admin, Create, Datagrid, Edit, EditButton, List, HttpError, Resource, SimpleForm,
3+
BulkDeleteButton, BulkExportButton, BulkUpdateButton,
34
SimpleShowLayout, Show,
45
BooleanField, BooleanInput,
56
DateField, DateInput,
@@ -61,6 +62,7 @@ const dataProvider = {
6162
},
6263
getOne: (resource, params) => dataRequest(resource, "get_one", params),
6364
update: (resource, params) => dataRequest(resource, "update", params),
65+
updateMany: (resource, params) => dataRequest(resource, "update_many", params)
6466
}
6567

6668
const authProvider = {
@@ -159,14 +161,40 @@ function createInputs(resource, name, perm_type, permissions) {
159161
return components;
160162
}
161163

162-
const AiohttpList = (resource, name, permissions) => (
163-
<List filters={createInputs(resource, name, "view", permissions)}>
164-
<Datagrid rowClick="show">
165-
{createFields(resource, name, permissions, true)}
166-
<WithRecord render={(record) => hasPermission(`${name}.edit`, permissions, record) && <EditButton />} />
167-
</Datagrid>
168-
</List>
169-
);
164+
function createBulkUpdates(resource, name, permissions) {
165+
let buttons = [];
166+
for (const [label, data] of Object.entries(resource["bulk_update"])) {
167+
let allowed = true;
168+
for (const k of Object.keys(data)) {
169+
if (!hasPermission(`${name}.${k}.edit`, permissions)) {
170+
allowed = false;
171+
break;
172+
}
173+
}
174+
if (allowed)
175+
buttons.push(<BulkUpdateButton label={label} data={data} />);
176+
}
177+
return buttons;
178+
}
179+
180+
const AiohttpList = (resource, name, permissions) => {
181+
const BulkActionButtons = () => (
182+
<>
183+
{hasPermission(`${name}.edit`, permissions) && createBulkUpdates(resource, name, permissions)}
184+
<BulkExportButton />
185+
{hasPermission(`${name}.delete`, permissions) && <BulkDeleteButton />}
186+
</>
187+
);
188+
189+
return (
190+
<List filters={createInputs(resource, name, "view", permissions)}>
191+
<Datagrid rowClick="show" bulkActionButtons={<BulkActionButtons />}>
192+
{createFields(resource, name, permissions, true)}
193+
<WithRecord render={(record) => hasPermission(`${name}.edit`, permissions, record) && <EditButton />} />
194+
</Datagrid>
195+
</List>
196+
);
197+
}
170198

171199
const AiohttpShow = (resource, name, permissions) => (
172200
<Show>

aiohttp_admin/backends/abc.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ class UpdateParams(_Params):
6767
previousData: Json[Record]
6868

6969

70+
class UpdateManyParams(_Params):
71+
ids: Json[list[Union[int, str]]]
72+
data: Json[Record]
73+
74+
7075
class DeleteParams(_Params):
7176
id: Union[int, str]
7277
previousData: Json[Record]
@@ -105,6 +110,10 @@ async def get_many(self, params: GetManyParams) -> list[Record]:
105110
async def update(self, params: UpdateParams) -> Record:
106111
"""Update the record and return the updated record."""
107112

113+
@abstractmethod
114+
async def update_many(self, params: UpdateManyParams) -> list[Union[int, str]]:
115+
"""Update multiple records and return the IDs of updated records."""
116+
108117
@abstractmethod
109118
async def create(self, params: CreateParams) -> Record:
110119
"""Create a new record and return the created record."""
@@ -192,6 +201,25 @@ async def _update(self, request: web.Request) -> web.Response:
192201
result = await self.filter_by_permissions(request, "view", result)
193202
return json_response({"data": result})
194203

204+
async def _update_many(self, request: web.Request) -> web.Response:
205+
await check_permission(request, f"admin.{self.name}.edit", context=(request, None))
206+
query = parse_obj_as(UpdateManyParams, request.query)
207+
208+
# Check original records are allowed by permission filters.
209+
originals = await self.get_many({"ids": query["ids"]})
210+
allowed = (permits(request, f"admin.{self.name}.edit", context=(request, r))
211+
for r in originals)
212+
allowed_f = (permits(request, f"admin.{self.name}.{k}.edit", context=(request, r))
213+
for r in originals for k in query["data"])
214+
if not all(await asyncio.gather(*allowed, *allowed_f)):
215+
raise web.HTTPForbidden()
216+
# Check new values are allowed by permission filters.
217+
if not await permits(request, f"admin.{self.name}.edit", context=(request, query["data"])):
218+
raise web.HTTPForbidden()
219+
220+
ids = await self.update_many(query)
221+
return json_response({"data": ids})
222+
195223
async def _delete(self, request: web.Request) -> web.Response:
196224
await check_permission(request, f"admin.{self.name}.delete", context=(request, None))
197225
query = parse_obj_as(DeleteParams, request.query)
@@ -230,6 +258,7 @@ def routes(self) -> tuple[web.RouteDef, ...]:
230258
web.get(url, self._get_many, name=self.name + "_get_many"),
231259
web.post(url, self._create, name=self.name + "_create"),
232260
web.put(url + "/update", self._update, name=self.name + "_update"),
261+
web.put(url + "/update_many", self._update_many, name=self.name + "_update_many"),
233262
web.delete(url + "/one", self._delete, name=self.name + "_delete"),
234263
web.delete(url, self._delete_many, name=self.name + "_delete_many")
235264
)

aiohttp_admin/backends/sqlalchemy.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from .abc import (
1212
AbstractAdminResource, CreateParams, DeleteManyParams, DeleteParams, GetListParams,
13-
GetManyParams, GetOneParams, Record, UpdateParams)
13+
GetManyParams, GetOneParams, Record, UpdateManyParams, UpdateParams)
1414

1515
logger = logging.getLogger(__name__)
1616

@@ -155,6 +155,19 @@ async def update(self, params: UpdateParams) -> Record:
155155
logger.warning("No result found (%s)", params["id"], exc_info=True)
156156
raise web.HTTPNotFound()
157157

158+
async def update_many(self, params: UpdateManyParams) -> list[Union[str, int]]:
159+
async with self._db.begin() as conn:
160+
stmt = sa.update(self._table).where(self._table.c["id"].in_(params["ids"]))
161+
stmt = stmt.values(params["data"]).returning(self._table.c["id"])
162+
try:
163+
r = await conn.scalars(stmt)
164+
except sa.exc.CompileError as e:
165+
logger.warning("CompileError (%s)", params["ids"], exc_info=True)
166+
raise web.HTTPBadRequest(reason=str(e))
167+
# The security check has already called get_many(), so we can be sure
168+
# there will be results here.
169+
return list(r)
170+
158171
async def delete(self, params: DeleteParams) -> Record:
159172
async with self._db.begin() as conn:
160173
stmt = sa.delete(self._table).where(self._table.c["id"] == params["id"])

aiohttp_admin/routes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ def setup_resources(admin: web.Application, schema: Schema) -> None:
3232
v["props"]["alwaysOn"] = "alwaysOn" # Always display filter
3333

3434
state = {"fields": m.fields, "inputs": m.inputs, "display": display_fields,
35-
"repr": repr_field, "label": r.get("label"), "icon": r.get("icon")}
35+
"repr": repr_field, "label": r.get("label"), "icon": r.get("icon"),
36+
"bulk_update": r.get("bulk_update", {})}
3637
admin["state"]["resources"][m.name] = state
3738

3839

aiohttp_admin/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ class _Resource(TypedDict, total=False):
6262
# name of the field that should be used for repr
6363
# (e.g. when displaying a foreign key reference).
6464
repr: str
65+
# Bulk update actions (which appear when selecting rows in the list view).
66+
# Format: {"Button Label": {"field_to_update": "value_to_set"}}
67+
# e.g. {"Reset Views": {"views": 0}}
68+
bulk_update: dict[str, dict[str, Any]]
6569

6670

6771
class Resource(_Resource):
@@ -85,6 +89,7 @@ class _ResourceState(TypedDict):
8589
repr: str
8690
icon: Optional[str]
8791
urls: dict[str, tuple[str, str]] # (method, url)
92+
bulk_update: dict[str, dict[str, Any]]
8893

8994

9095
class State(TypedDict):

examples/permissions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ async def create_app() -> web.Application:
9999
"secure": False
100100
},
101101
"resources": (
102-
{"model": SAResource(engine, Simple)},
102+
{"model": SAResource(engine, Simple),
103+
"bulk_update": {"Set to 7": {"optional_num": 7}}},
103104
{"model": SAResource(engine, SimpleParent)}
104105
)
105106
}

tests/test_security.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,24 @@ async def identity_callback(identity: Optional[str]) -> UserDetails:
317317
assert r.msg == "Test"
318318

319319

320+
async def test_update_many_resource_finegrained_permission( # type: ignore[no-any-unimported]
321+
create_admin_client: _CreateClient, login: _Login) -> None:
322+
async def identity_callback(identity: Optional[str]) -> UserDetails:
323+
assert identity == "admin"
324+
return {"permissions": {"admin.*", "~admin.dummy2.msg.edit"}}
325+
326+
admin_client = await create_admin_client(identity_callback)
327+
328+
assert admin_client.app
329+
url = admin_client.app["admin"].router["dummy2_update_many"].url_for()
330+
h = await login(admin_client)
331+
p = {"ids": "[1]", "data": json.dumps({"msg": "ABC"})}
332+
async with admin_client.put(url, params=p, headers=h) as resp:
333+
assert resp.status == 403
334+
# TODO(aiohttp-security05)
335+
# expected = "403: User does not have 'admin.dummy2.msg.edit' permission"
336+
337+
320338
async def test_delete_resource_filtered_permission(create_admin_client: _CreateClient, # type: ignore[no-any-unimported] # noqa: B950
321339
login: _Login) -> None:
322340
async def identity_callback(identity: Optional[str]) -> UserDetails:
@@ -543,6 +561,50 @@ async def identity_callback(identity: Optional[str]) -> UserDetails:
543561
assert resp.status == 200
544562

545563

564+
async def test_permission_filter_update_many( # type: ignore[no-any-unimported]
565+
create_admin_client: _CreateClient, login: _Login
566+
) -> None:
567+
async def identity_callback(identity: Optional[str]) -> UserDetails:
568+
return {"permissions": ("admin.*", 'admin.dummy2.*|msg="Test"')}
569+
570+
admin_client = await create_admin_client(identity_callback)
571+
572+
assert admin_client.app
573+
url = admin_client.app["admin"].router["dummy2_update_many"].url_for()
574+
h = await login(admin_client)
575+
p = {"ids": "[3]", "data": json.dumps({"msg": "Test"})}
576+
async with admin_client.put(url, params=p, headers=h) as resp:
577+
assert resp.status == 403
578+
p = {"ids": "[1]", "data": json.dumps({"msg": "Foo"})}
579+
async with admin_client.put(url, params=p, headers=h) as resp:
580+
assert resp.status == 403
581+
p = {"ids": "[1, 2]", "data": json.dumps({"msg": "Test"})}
582+
async with admin_client.put(url, params=p, headers=h) as resp:
583+
assert resp.status == 200
584+
585+
586+
async def test_permission_filter_update_many2( # type: ignore[no-any-unimported]
587+
create_admin_client: _CreateClient, login: _Login
588+
) -> None:
589+
async def identity_callback(identity: Optional[str]) -> UserDetails:
590+
return {"permissions": ("admin.*", 'admin.dummy2.edit|msg="Test"')}
591+
592+
admin_client = await create_admin_client(identity_callback)
593+
594+
assert admin_client.app
595+
url = admin_client.app["admin"].router["dummy2_update_many"].url_for()
596+
h = await login(admin_client)
597+
p = {"ids": "[3]", "data": json.dumps({"msg": "Test"})}
598+
async with admin_client.put(url, params=p, headers=h) as resp:
599+
assert resp.status == 403
600+
p = {"ids": "[1]", "data": json.dumps({"msg": "Foo"})}
601+
async with admin_client.put(url, params=p, headers=h) as resp:
602+
assert resp.status == 403
603+
p = {"ids": "[1, 2]", "data": json.dumps({"msg": "Test"})}
604+
async with admin_client.put(url, params=p, headers=h) as resp:
605+
assert resp.status == 200
606+
607+
546608
async def test_permission_filter_delete(create_admin_client: _CreateClient, # type: ignore[no-any-unimported] # noqa: B950
547609
login: _Login) -> None:
548610
async def identity_callback(identity: Optional[str]) -> UserDetails:
@@ -823,3 +885,43 @@ async def identity_callback(identity: Optional[str]) -> UserDetails:
823885
assert r is None
824886
r = await sess.get(admin_client.app["model2"], 5)
825887
assert r.msg == "Test"
888+
889+
890+
async def test_permission_filter_field_update_many( # type: ignore[no-any-unimported]
891+
create_admin_client: _CreateClient, login: _Login
892+
) -> None:
893+
async def identity_callback(identity: Optional[str]) -> UserDetails:
894+
return {"permissions": ("admin.*", "admin.dummy2.msg.*|id=1|id=2")}
895+
896+
admin_client = await create_admin_client(identity_callback)
897+
898+
assert admin_client.app
899+
url = admin_client.app["admin"].router["dummy2_update_many"].url_for()
900+
h = await login(admin_client)
901+
p = {"ids": "[3]", "data": json.dumps({"msg": "Spam"})}
902+
async with admin_client.put(url, params=p, headers=h) as resp:
903+
assert resp.status == 403
904+
p = {"ids": "[1, 2]", "data": json.dumps({"msg": "Spam"})}
905+
async with admin_client.put(url, params=p, headers=h) as resp:
906+
assert resp.status == 200
907+
assert await resp.json() == {"data": [1, 2]}
908+
909+
910+
async def test_permission_filter_field_update_many2( # type: ignore[no-any-unimported]
911+
create_admin_client: _CreateClient, login: _Login
912+
) -> None:
913+
async def identity_callback(identity: Optional[str]) -> UserDetails:
914+
return {"permissions": ("admin.*", "admin.dummy2.msg.edit|id=1|id=2")}
915+
916+
admin_client = await create_admin_client(identity_callback)
917+
918+
assert admin_client.app
919+
url = admin_client.app["admin"].router["dummy2_update_many"].url_for()
920+
h = await login(admin_client)
921+
p = {"ids": "[3]", "data": json.dumps({"msg": "Spam"})}
922+
async with admin_client.put(url, params=p, headers=h) as resp:
923+
assert resp.status == 403
924+
p = {"ids": "[1, 2]", "data": json.dumps({"msg": "Spam"})}
925+
async with admin_client.put(url, params=p, headers=h) as resp:
926+
assert resp.status == 200
927+
assert await resp.json() == {"data": [1, 2]}

tests/test_views.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ async def test_update(admin_client: TestClient, login: _Login) -> None:
186186
r = await sess.get(admin_client.app["model"], 4)
187187
assert r.id == 4
188188

189+
assert await sess.get(admin_client.app["model"], 1) is None
189190
assert await sess.get(admin_client.app["model"], 2) is None
190191

191192

@@ -208,6 +209,41 @@ async def test_update_invalid_attributes(admin_client: TestClient, login: _Login
208209
assert "foo" in await resp.text()
209210

210211

212+
async def test_update_many(admin_client: TestClient, login: _Login) -> None:
213+
h = await login(admin_client)
214+
assert admin_client.app
215+
url = admin_client.app["admin"].router["dummy2_update_many"].url_for()
216+
p = {"ids": "[1, 2]", "data": json.dumps({"msg": "ABC"})}
217+
async with admin_client.put(url, params=p, headers=h) as resp:
218+
assert resp.status == 200
219+
assert await resp.json() == {"data": [1, 2]}
220+
221+
async with admin_client.app["db"]() as sess:
222+
r = await sess.get(admin_client.app["model2"], 1)
223+
assert r.msg == "ABC"
224+
r = await sess.get(admin_client.app["model2"], 2)
225+
assert r.msg == "ABC"
226+
227+
228+
async def test_update_many_deleted_entity(admin_client: TestClient, login: _Login) -> None:
229+
h = await login(admin_client)
230+
assert admin_client.app
231+
url = admin_client.app["admin"].router["dummy_update_many"].url_for()
232+
p = {"ids": "[2]", "data": '{"id": 4}'}
233+
async with admin_client.put(url, params=p, headers=h) as resp:
234+
assert resp.status == 404
235+
236+
237+
async def test_update_many_invalid_attributes(admin_client: TestClient, login: _Login) -> None:
238+
h = await login(admin_client)
239+
assert admin_client.app
240+
url = admin_client.app["admin"].router["dummy_update_many"].url_for()
241+
p = {"ids": "[1]", "data": '{"foo": "invalid"}'}
242+
async with admin_client.put(url, params=p, headers=h) as resp:
243+
assert resp.status == 400
244+
assert "foo" in await resp.text()
245+
246+
211247
async def test_delete(admin_client: TestClient, login: _Login) -> None:
212248
h = await login(admin_client)
213249
assert admin_client.app

0 commit comments

Comments
 (0)