Skip to content

Commit

Permalink
Use evaluate() function with Component/Function types (#757)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dreamsorcerer committed Jul 30, 2023
1 parent 1835531 commit aabcea7
Show file tree
Hide file tree
Showing 18 changed files with 296 additions and 223 deletions.
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ repos:
hooks:
- id: check-merge-conflict
- repo: https://github.com/asottile/yesqa
rev: v1.4.0
rev: v1.5.0
hooks:
- id: yesqa
additional_dependencies: ["flake8-bandit", "flake8-bugbear"]
Expand Down Expand Up @@ -38,7 +38,7 @@ repos:
- id: detect-private-key
exclude: ^examples/
- repo: https://github.com/PyCQA/flake8
rev: '6.0.0'
rev: '6.1.0'
hooks:
- id: flake8
exclude: "^docs/"
Expand Down
94 changes: 48 additions & 46 deletions admin-js/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ const COMPONENTS = {
BooleanInput, DateInput, DateTimeInput, NumberInput, ReferenceInput, SelectInput,
TextInput, TimeInput
};
const USER_FUNCS = {};
const VALIDATORS = {email, maxLength, maxValue, minLength, minValue, regex, required};
const FUNCTIONS = {email, maxLength, maxValue, minLength, minValue, regex, required};
const _body = document.querySelector("body");
const STATE = Object.freeze(JSON.parse(_body.dataset.state));

Expand All @@ -76,8 +75,7 @@ if (STATE["js_module"]) {
// browser's import() function. Needed to dynamically import a module.
MODULE_LOADER = import(/* webpackIgnore: true */ STATE["js_module"]).then((mod) => {
Object.assign(COMPONENTS, mod.components);
Object.assign(VALIDATORS, mod.validators);
Object.assign(USER_FUNCS, mod.funcs);
Object.assign(FUNCTIONS, mod.functions);
});
} else {
MODULE_LOADER = Promise.resolve();
Expand Down Expand Up @@ -157,46 +155,58 @@ const authProvider = {
},
};

function evaluate(obj) {
if (obj === null || obj === undefined)
return obj;
if (Array.isArray(obj))
return obj.map(evaluate);
if (obj["__type__"] === "component") {
const C = COMPONENTS[obj["type"]];
if (C === undefined)
throw Error(`Unknown component '${obj["type"]}'`);

let {children, ...props} = obj["props"];
props = Object.fromEntries(Object.entries(props).map(([k, v]) => [k, evaluate(v)]));
if (children)
return <C {...props}>{evaluate(children)}</C>;
return <C {...props} />;
}
if (obj["__type__"] === "function") {
const f = FUNCTIONS[obj["name"]];
if (f === undefined)
throw Error(`Unknown function '${obj["name"]}'`);
if (obj["args"] === null)
return f;
return f(...evaluate(obj["args"]));
}
if (obj["__type__"] === "regexp")
return new RegExp(obj["value"]);
return obj;
}


function createFields(resource, name, permissions) {
function createFields(fields, name, permissions) {
let components = [];
for (const [field, state] of Object.entries(resource["fields"])) {
for (const [field, state] of Object.entries(fields)) {
if (!hasPermission(`${name}.${field}.view`, permissions))
continue;

const C = COMPONENTS[state["type"]];
if (C === undefined)
throw Error(`Unknown component '${state["type"]}'`);

const {children, ...props} = state["props"];
let c;
if (children) {
let child_fields = createFields(
{"fields": children, "display": Object.keys(children)}, name, permissions);
c = <C source={field} {...props}>{child_fields}</C>;
} else {
c = <C source={field} {...props} />;
}
if (field === "_") {
// Layout component, not related to a specific field.
components.push(c);
} else {
const withRecordProps = {
"source": field, "label": props["label"], "sortable": props["sortable"],
"sortBy": props["sortBy"], "sortByOrder": props["sortByOrder"]}
// Show icon if user doesn't have permission to view this field (based on filters).
components.push(<WithRecord {...withRecordProps} render={
(record) => hasPermission(`${name}.${field}.view`, permissions, record) ? c : <VisibilityOffIcon />
} />);
}
const c = evaluate(state);
const withRecordPropNames = ["label", "sortable", "sortBy", "sortByOrder"];
const withRecordProps = Object.fromEntries(withRecordPropNames.map(
(k) => [k, evaluate(state["props"][k])]));
// Show icon if user doesn't have permission to view this field (based on filters).
components.push(<WithRecord source={field} {...withRecordProps} render={
(record) => hasPermission(`${name}.${field}.view`, permissions, record) ? c : <VisibilityOffIcon />
} />);
}
return components;
}

function createInputs(resource, name, perm_type, permissions) {
let components = [];
const resource_filters = getFilters(name, perm_type, permissions);
for (const [field, state] of Object.entries(resource["inputs"])) {
for (let [field, state] of Object.entries(resource["inputs"])) {
if ((perm_type === "add" && !state["show_create"])
|| !hasPermission(`${name}.${field}.${perm_type}`, permissions))
continue;
Expand All @@ -216,19 +226,11 @@ function createInputs(resource, name, perm_type, permissions) {
<SelectInput source={field} choices={choices} defaultValue={nullable < 0 && fvalues[0]}
validate={nullable < 0 && required()} disabled={disabled} />);
} else {
const C = COMPONENTS[state["type"]];
if (C === undefined)
throw Error(`Unknown component '${state["type"]}'`);

let validators = [];
if (perm_type !== "view") {
for (let validator of state["validators"]) {
if (validator[0] === "regex")
validator[1] = new RegExp(validator[1]);
validators.push(VALIDATORS[validator[0]](...validator.slice(1)))
}
if (perm_type === "view") {
state = structuredClone(state);
delete state["props"]["validate"];
}
const c = <C source={field} validate={validators} {...state["props"]} />;
const c = evaluate(state);
if (perm_type === "edit")
// Don't render if filters disallow editing this field.
components.push(<WithRecord source={field} render={
Expand Down Expand Up @@ -277,7 +279,7 @@ const AiohttpList = (resource, name, permissions) => {
return (
<List actions={<ListActions />} filters={createInputs(resource, name, "view", permissions)}>
<DatagridConfigurable omit={resource["list_omit"]} rowClick="show" bulkActionButtons={<BulkActionButtons />}>
{createFields(resource, name, permissions)}
{createFields(resource["fields"], name, permissions)}
<WithRecord label="[Edit]" render={(record) => hasPermission(`${name}.edit`, permissions, record) && <EditButton />} />
</DatagridConfigurable>
</List>
Expand All @@ -294,7 +296,7 @@ const AiohttpShow = (resource, name, permissions) => {
return (
<Show actions={<ShowActions />}>
<SimpleShowLayout>
{createFields(resource, name, permissions)}
{createFields(resource["fields"], name, permissions)}
</SimpleShowLayout>
</Show>
);
Expand Down
6 changes: 3 additions & 3 deletions aiohttp_admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
from aiohttp import web
from aiohttp.typedefs import Handler
from aiohttp_session.cookie_storage import EncryptedCookieStorage
from pydantic import ValidationError, parse_obj_as
from pydantic import ValidationError

from .routes import setup_resources, setup_routes
from .security import AdminAuthorizationPolicy, Permissions, TokenIdentityPolicy
from .security import AdminAuthorizationPolicy, Permissions, TokenIdentityPolicy, check
from .types import Schema, UserDetails

__all__ = ("Permissions", "Schema", "UserDetails", "setup")
Expand Down Expand Up @@ -67,7 +67,7 @@ def value(r: web.RouteDef) -> tuple[str, str]:
m = res["model"]
admin["state"]["resources"][m.name]["urls"] = {key(r): value(r) for r in m.routes}

schema = parse_obj_as(Schema, schema)
schema = check(Schema, schema)
if secret is None:
secret = secrets.token_bytes()

Expand Down
42 changes: 24 additions & 18 deletions aiohttp_admin/backends/abc.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import asyncio
import json
import sys
import warnings
from abc import ABC, abstractmethod
from datetime import date, datetime, time
from enum import Enum
from functools import cached_property, partial
from types import MappingProxyType
from typing import Any, Literal, Optional, TypedDict, Union
from typing import Any, Literal, Optional, Union

from aiohttp import web
from aiohttp_security import check_permission, permits
from pydantic import Json, parse_obj_as
from pydantic import Json

from ..security import permissions_as_dict
from ..types import FieldState, InputState
from ..security import check, permissions_as_dict
from ..types import ComponentState, InputState

if sys.version_info >= (3, 12):
from typing import TypedDict
else:
from typing_extensions import TypedDict

Record = dict[str, object]

Expand Down Expand Up @@ -93,7 +99,7 @@ class DeleteManyParams(_Params):

class AbstractAdminResource(ABC):
name: str
fields: dict[str, FieldState]
fields: dict[str, ComponentState]
inputs: dict[str, InputState]
primary_key: str
omit_fields: set[str]
Expand Down Expand Up @@ -149,7 +155,7 @@ async def delete_many(self, params: DeleteManyParams) -> list[Union[int, str]]:

async def _get_list(self, request: web.Request) -> web.Response:
await check_permission(request, f"admin.{self.name}.view", context=(request, None))
query = parse_obj_as(GetListParams, request.query)
query = check(GetListParams, request.query)

# When sort order refers to "id", this should be translated to primary key.
if query["sort"]["field"] == "id":
Expand All @@ -174,7 +180,7 @@ async def _get_list(self, request: web.Request) -> web.Response:

async def _get_one(self, request: web.Request) -> web.Response:
await check_permission(request, f"admin.{self.name}.view", context=(request, None))
query = parse_obj_as(GetOneParams, request.query)
query = check(GetOneParams, request.query)

result = await self.get_one(query)
if not await permits(request, f"admin.{self.name}.view", context=(request, result)):
Expand All @@ -185,7 +191,7 @@ async def _get_one(self, request: web.Request) -> web.Response:

async def _get_many(self, request: web.Request) -> web.Response:
await check_permission(request, f"admin.{self.name}.view", context=(request, None))
query = parse_obj_as(GetManyParams, request.query)
query = check(GetManyParams, request.query)

results = await self.get_many(query)
if not results:
Expand All @@ -198,12 +204,12 @@ async def _get_many(self, request: web.Request) -> web.Response:
return json_response({"data": results})

async def _create(self, request: web.Request) -> web.Response:
query = parse_obj_as(CreateParams, request.query)
query = check(CreateParams, request.query)
# TODO(Pydantic): Dissallow extra arguments
for k in query["data"]:
if k not in self.inputs and k != "id":
raise web.HTTPBadRequest(reason=f"Invalid field '{k}'")
query["data"] = parse_obj_as(self._record_type, query["data"])
query["data"] = check(self._record_type, query["data"])
await check_permission(request, f"admin.{self.name}.add", context=(request, query["data"]))
for k, v in query["data"].items():
if v is not None:
Expand All @@ -217,13 +223,13 @@ async def _create(self, request: web.Request) -> web.Response:

async def _update(self, request: web.Request) -> web.Response:
await check_permission(request, f"admin.{self.name}.edit", context=(request, None))
query = parse_obj_as(UpdateParams, request.query)
query = check(UpdateParams, request.query)
# TODO(Pydantic): Dissallow extra arguments
for k in query["data"]:
if k not in self.inputs and k != "id":
raise web.HTTPBadRequest(reason=f"Invalid field '{k}'")
query["data"] = parse_obj_as(self._record_type, query["data"])
query["previousData"] = parse_obj_as(self._record_type, query["previousData"])
query["data"] = check(self._record_type, query["data"])
query["previousData"] = check(self._record_type, query["previousData"])

if self.primary_key != "id":
query["data"].pop("id", None)
Expand Down Expand Up @@ -251,12 +257,12 @@ async def _update(self, request: web.Request) -> web.Response:

async def _update_many(self, request: web.Request) -> web.Response:
await check_permission(request, f"admin.{self.name}.edit", context=(request, None))
query = parse_obj_as(UpdateManyParams, request.query)
query = check(UpdateManyParams, request.query)
# TODO(Pydantic): Dissallow extra arguments
for k in query["data"]:
if k not in self.inputs and k != "id":
raise web.HTTPBadRequest(reason=f"Invalid field '{k}'")
query["data"] = parse_obj_as(self._record_type, query["data"])
query["data"] = check(self._record_type, query["data"])

# Check original records are allowed by permission filters.
originals = await self.get_many({"ids": query["ids"]})
Expand All @@ -278,8 +284,8 @@ async def _update_many(self, request: web.Request) -> web.Response:

async def _delete(self, request: web.Request) -> web.Response:
await check_permission(request, f"admin.{self.name}.delete", context=(request, None))
query = parse_obj_as(DeleteParams, request.query)
query["previousData"] = parse_obj_as(self._record_type, query["previousData"])
query = check(DeleteParams, request.query)
query["previousData"] = check(self._record_type, query["previousData"])

original = await self.get_one({"id": query["id"]})
if not await permits(request, f"admin.{self.name}.delete", context=(request, original)):
Expand All @@ -292,7 +298,7 @@ async def _delete(self, request: web.Request) -> web.Response:

async def _delete_many(self, request: web.Request) -> web.Response:
await check_permission(request, f"admin.{self.name}.delete", context=(request, None))
query = parse_obj_as(DeleteManyParams, request.query)
query = check(DeleteManyParams, request.query)

originals = await self.get_many(query)
allowed = await asyncio.gather(*(permits(request, f"admin.{self.name}.delete",
Expand Down
Loading

0 comments on commit aabcea7

Please sign in to comment.