mirror of
https://github.com/ceph/ceph
synced 2025-04-01 23:02:17 +00:00
mgr/dashboard: create authx users
Signed-off-by: Pere Diaz Bou <pdiazbou@redhat.com> Signed-off-by: Nizamudeen A <nia@redhat.com> Co-authored-by: Nizamudeen A <nia@redhat.com>
This commit is contained in:
parent
cb17f28627
commit
10f17bd9eb
@ -1,5 +1,8 @@
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from inspect import isclass
|
||||
from typing import Any, Callable, Dict, Generator, Iterable, Iterator, List, \
|
||||
NamedTuple, Optional, get_type_hints
|
||||
NamedTuple, Optional, Union, get_type_hints
|
||||
|
||||
from ._api_router import APIRouter
|
||||
from ._docs import APIDoc, EndpointDoc
|
||||
@ -17,6 +20,7 @@ def isnamedtuple(o):
|
||||
|
||||
def serialize(o, expected_type=None):
|
||||
# pylint: disable=R1705,W1116
|
||||
print(o, expected_type)
|
||||
if isnamedtuple(o):
|
||||
hints = get_type_hints(o)
|
||||
return {k: serialize(v, hints[k]) for k, v in zip(o._fields, o)}
|
||||
@ -28,7 +32,7 @@ def serialize(o, expected_type=None):
|
||||
return [serialize(i) for i in o]
|
||||
elif isinstance(o, (Iterator, Generator)):
|
||||
return [serialize(i) for i in o]
|
||||
elif expected_type and issubclass(expected_type, SecretStr):
|
||||
elif expected_type and isclass(expected_type) and issubclass(expected_type, SecretStr):
|
||||
return "***********"
|
||||
else:
|
||||
return o
|
||||
@ -41,14 +45,235 @@ class TableColumn(NamedTuple):
|
||||
filterable: bool = True
|
||||
|
||||
|
||||
class TableAction(NamedTuple):
|
||||
name: str
|
||||
permission: str
|
||||
icon: str
|
||||
routerLink: str # redirect to...
|
||||
|
||||
|
||||
class TableComponent(NamedTuple):
|
||||
columns: List[TableColumn] = []
|
||||
columnMode: str = 'flex'
|
||||
toolHeader: bool = True
|
||||
|
||||
|
||||
class Icon(Enum):
|
||||
add = 'fa fa-plus'
|
||||
|
||||
|
||||
class FormField(NamedTuple):
|
||||
"""
|
||||
The key of a FromField is then used to send the data related to that key into the
|
||||
POST and PUT endpoints. It is imperative for the developer to map keys of fields and containers
|
||||
to the input of the POST and PUT endpoints.
|
||||
"""
|
||||
name: str
|
||||
key: str
|
||||
field_type: Any = str
|
||||
default_value: Optional[Any] = None
|
||||
optional: bool = False
|
||||
html_class: str = ''
|
||||
label_html_class: str = 'col-form-label'
|
||||
field_html_class: str = 'col-form-input'
|
||||
|
||||
def get_type(self):
|
||||
_type = ''
|
||||
if self.field_type == str:
|
||||
_type = 'string'
|
||||
elif self.field_type == int:
|
||||
_type = 'integer'
|
||||
elif self.field_type == bool:
|
||||
_type = 'boolean'
|
||||
else:
|
||||
raise NotImplementedError(f'Unimplemented type {self.field_type}')
|
||||
return _type
|
||||
|
||||
|
||||
class Container:
|
||||
def __init__(self, name: str, key: str, fields: List[Union[FormField, "Container"]],
|
||||
optional: bool = False, html_class: str = '', label_html_class: str = '',
|
||||
field_html_class: str = ''):
|
||||
self.name = name
|
||||
self.key = key
|
||||
self.fields = fields
|
||||
self.optional = optional
|
||||
self.html_class = html_class
|
||||
self.label_html_class = label_html_class
|
||||
self.field_html_class = field_html_class
|
||||
|
||||
def layout_type(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _property_type(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def to_dict(self, key=''):
|
||||
# intialize the schema of this container
|
||||
ui_schemas = []
|
||||
control_schema = {
|
||||
'type': self._property_type(),
|
||||
'title': self.name
|
||||
}
|
||||
items = None # layout items alias as it depends on the type of container
|
||||
properties = None # control schema properties alias
|
||||
required = None
|
||||
if self._property_type() == 'array':
|
||||
control_schema['items'] = {
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
'required': []
|
||||
}
|
||||
properties = control_schema['items']['properties']
|
||||
required = control_schema['items']['required']
|
||||
ui_schemas.append({
|
||||
'type': 'array',
|
||||
'key': key,
|
||||
'htmlClass': self.html_class,
|
||||
'fieldHtmlClass': self.field_html_class,
|
||||
'labelHtmlClass': self.label_html_class,
|
||||
'items': [{
|
||||
'type': 'div',
|
||||
'flex-direction': self.layout_type(),
|
||||
'displayFlex': True,
|
||||
'items': []
|
||||
}]
|
||||
})
|
||||
items = ui_schemas[-1]['items'][0]['items']
|
||||
else:
|
||||
control_schema['properties'] = {}
|
||||
control_schema['required'] = []
|
||||
required = control_schema['required']
|
||||
properties = control_schema['properties']
|
||||
ui_schemas.append({
|
||||
'type': 'section',
|
||||
'flex-direction': self.layout_type(),
|
||||
'displayFlex': True,
|
||||
'htmlClass': self.html_class,
|
||||
'fieldHtmlClass': self.field_html_class,
|
||||
'labelHtmlClass': self.label_html_class,
|
||||
'key': key,
|
||||
'items': []
|
||||
})
|
||||
if key:
|
||||
items = ui_schemas[-1]['items']
|
||||
else:
|
||||
items = ui_schemas
|
||||
|
||||
assert items is not None
|
||||
assert properties is not None
|
||||
assert required is not None
|
||||
|
||||
# include fields in this container's schema
|
||||
for field in self.fields:
|
||||
field_ui_schema = {}
|
||||
properties[field.key] = {}
|
||||
field_key = field.key
|
||||
if key:
|
||||
if self._property_type() == 'array':
|
||||
field_key = key + '[].' + field.key
|
||||
else:
|
||||
field_key = key + '.' + field.key
|
||||
|
||||
if isinstance(field, FormField):
|
||||
_type = field.get_type()
|
||||
properties[field.key]['type'] = _type
|
||||
properties[field.key]['title'] = field.name
|
||||
field_ui_schema['key'] = field_key
|
||||
field_ui_schema['htmlClass'] = field.html_class
|
||||
field_ui_schema['fieldHtmlClass'] = field.field_html_class
|
||||
field_ui_schema['labelHtmlClass'] = field.label_html_class
|
||||
items.append(field_ui_schema)
|
||||
elif isinstance(field, Container):
|
||||
container_schema = field.to_dict(key+'.'+field.key if key else field.key)
|
||||
control_schema['properties'][field.key] = container_schema['control_schema']
|
||||
ui_schemas.extend(container_schema['ui_schema'])
|
||||
if not field.optional:
|
||||
required.append(field.key)
|
||||
return {
|
||||
'ui_schema': ui_schemas,
|
||||
'control_schema': control_schema,
|
||||
}
|
||||
|
||||
|
||||
class VerticalContainer(Container):
|
||||
def layout_type(self):
|
||||
return 'column'
|
||||
|
||||
def _property_type(self):
|
||||
return 'object'
|
||||
|
||||
|
||||
class HorizontalContainer(Container):
|
||||
def layout_type(self):
|
||||
return 'row'
|
||||
|
||||
def _property_type(self):
|
||||
return 'object'
|
||||
|
||||
|
||||
class ArrayVerticalContainer(Container):
|
||||
def layout_type(self):
|
||||
return 'column'
|
||||
|
||||
def _property_type(self):
|
||||
return 'array'
|
||||
|
||||
|
||||
class ArrayHorizontalContainer(Container):
|
||||
def layout_type(self):
|
||||
return 'row'
|
||||
|
||||
def _property_type(self):
|
||||
return 'array'
|
||||
|
||||
|
||||
class Form:
|
||||
def __init__(self, path, root_container, action: str = '',
|
||||
footer_html_class: str = 'card-footer position-absolute pb-0 mt-3',
|
||||
submit_style: str = 'btn btn-primary', cancel_style: str = ''):
|
||||
self.path = path
|
||||
self.action = action
|
||||
self.root_container = root_container
|
||||
self.footer_html_class = footer_html_class
|
||||
self.submit_style = submit_style
|
||||
self.cancel_style = cancel_style
|
||||
|
||||
def to_dict(self):
|
||||
container_schema = self.root_container.to_dict()
|
||||
|
||||
# root container style
|
||||
container_schema['ui_schema'].append({
|
||||
'type': 'flex',
|
||||
'flex-flow': f'{self.root_container.layout_type()} wrap',
|
||||
'displayFlex': True,
|
||||
})
|
||||
|
||||
footer = {
|
||||
"type": "flex",
|
||||
"htmlClass": self.footer_html_class,
|
||||
"items": [
|
||||
{
|
||||
'type': 'flex',
|
||||
'flex-direction': 'row',
|
||||
'displayFlex': True,
|
||||
'htmlClass': 'd-flex justify-content-end mb-0',
|
||||
'items': [
|
||||
{"type": "cancel", "style": self.cancel_style, 'htmlClass': 'mr-2'},
|
||||
{"type": "submit", "style": self.submit_style, "title": self.action},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
container_schema['ui_schema'].append(footer)
|
||||
return container_schema
|
||||
|
||||
|
||||
class CRUDMeta(NamedTuple):
|
||||
table: TableComponent = TableComponent()
|
||||
permissions: List[str] = []
|
||||
actions: List[Dict[str, Any]] = []
|
||||
forms: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
class CRUDCollectionMethod(NamedTuple):
|
||||
@ -65,8 +290,12 @@ class CRUDEndpoint(NamedTuple):
|
||||
router: APIRouter
|
||||
doc: APIDoc
|
||||
set_column: Optional[Dict[str, Dict[str, str]]] = None
|
||||
actions: List[TableAction] = []
|
||||
permissions: List[str] = []
|
||||
forms: List[Form] = []
|
||||
meta: CRUDMeta = CRUDMeta()
|
||||
get_all: Optional[CRUDCollectionMethod] = None
|
||||
create: Optional[CRUDCollectionMethod] = None
|
||||
|
||||
# for testing purposes
|
||||
CRUDClass: Optional[RESTController] = None
|
||||
@ -89,9 +318,19 @@ class CRUDEndpoint(NamedTuple):
|
||||
|
||||
if self.get_all:
|
||||
@self.get_all.doc
|
||||
def list(self):
|
||||
return serialize(cls(**item)
|
||||
for item in outer_self.get_all.func()) # type: ignore
|
||||
@wraps(self.get_all.func)
|
||||
def list(self, *args, **kwargs):
|
||||
items = []
|
||||
for item in outer_self.get_all.func(self, *args, **kwargs): # type: ignore
|
||||
items.append(serialize(cls(**item)))
|
||||
return items
|
||||
|
||||
if self.create:
|
||||
@self.create.doc
|
||||
@wraps(self.create.func)
|
||||
def create(self, *args, **kwargs):
|
||||
return outer_self.create.func(self, *args, **kwargs) # type: ignore
|
||||
|
||||
cls.CRUDClass = CRUDClass
|
||||
|
||||
def create_meta_class(self, cls):
|
||||
@ -101,6 +340,9 @@ class CRUDEndpoint(NamedTuple):
|
||||
class CRUDClassMetadata(RESTController):
|
||||
def list(self):
|
||||
self.update_columns()
|
||||
self.generate_actions()
|
||||
self.generate_forms()
|
||||
self.set_permissions()
|
||||
return serialize(outer_self.meta)
|
||||
|
||||
def update_columns(self):
|
||||
@ -114,4 +356,20 @@ class CRUDEndpoint(NamedTuple):
|
||||
column.filterable)
|
||||
outer_self.meta.table.columns[i] = new_column
|
||||
|
||||
def generate_actions(self):
|
||||
outer_self.meta.actions.clear()
|
||||
|
||||
for action in outer_self.actions:
|
||||
outer_self.meta.actions.append(action._asdict())
|
||||
|
||||
def generate_forms(self):
|
||||
outer_self.meta.forms.clear()
|
||||
|
||||
for form in outer_self.forms:
|
||||
outer_self.meta.forms.append(form.to_dict())
|
||||
|
||||
def set_permissions(self):
|
||||
if outer_self.permissions:
|
||||
outer_self.meta.permissions.extend(outer_self.permissions)
|
||||
|
||||
cls.CRUDClassMetadata = CRUDClassMetadata
|
||||
|
@ -83,7 +83,7 @@ class RESTController(BaseController, skip_registry=True):
|
||||
result = super().endpoints()
|
||||
res_id_params = cls.infer_resource_id()
|
||||
|
||||
for _, func in inspect.getmembers(cls, predicate=callable):
|
||||
for name, func in inspect.getmembers(cls, predicate=callable):
|
||||
endpoint_params = {
|
||||
'no_resource_id_params': False,
|
||||
'status': 200,
|
||||
@ -94,10 +94,9 @@ class RESTController(BaseController, skip_registry=True):
|
||||
'sec_permissions': hasattr(func, '_security_permissions'),
|
||||
'permission': None,
|
||||
}
|
||||
|
||||
if func.__name__ in cls._method_mapping:
|
||||
if name in cls._method_mapping:
|
||||
cls._update_endpoint_params_method_map(
|
||||
func, res_id_params, endpoint_params)
|
||||
func, res_id_params, endpoint_params, name=name)
|
||||
|
||||
elif hasattr(func, "__collection_method__"):
|
||||
cls._update_endpoint_params_collection_map(func, endpoint_params)
|
||||
@ -166,8 +165,8 @@ class RESTController(BaseController, skip_registry=True):
|
||||
endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
|
||||
|
||||
@classmethod
|
||||
def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params):
|
||||
meth = cls._method_mapping[func.__name__] # type: dict
|
||||
def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params, name=None):
|
||||
meth = cls._method_mapping[func.__name__ if not name else name] # type: dict
|
||||
|
||||
if meth['resource']:
|
||||
if not res_id_params:
|
||||
|
@ -1,8 +1,14 @@
|
||||
from typing import NamedTuple
|
||||
import logging
|
||||
from errno import EINVAL
|
||||
from typing import List, NamedTuple
|
||||
|
||||
from ..exceptions import DashboardException
|
||||
from ..security import Scope
|
||||
from ..services.ceph_service import CephService
|
||||
from ..services.ceph_service import CephService, SendCommandError
|
||||
from . import APIDoc, APIRouter, CRUDCollectionMethod, CRUDEndpoint, EndpointDoc, SecretStr
|
||||
from ._crud import ArrayHorizontalContainer, Form, FormField, Icon, TableAction, VerticalContainer
|
||||
|
||||
logger = logging.getLogger("controllers.ceph_users")
|
||||
|
||||
|
||||
class CephUserCaps(NamedTuple):
|
||||
@ -12,16 +18,83 @@ class CephUserCaps(NamedTuple):
|
||||
mds: str
|
||||
|
||||
|
||||
class Cap(NamedTuple):
|
||||
entity: str
|
||||
cap: str
|
||||
|
||||
|
||||
class CephUserEndpoints:
|
||||
@staticmethod
|
||||
def user_list(_):
|
||||
"""
|
||||
Get list of ceph users and its respective data
|
||||
"""
|
||||
return CephService.send_command('mon', 'auth ls')["auth_dump"]
|
||||
|
||||
@staticmethod
|
||||
def user_create(_, user_entity: str, capabilities: List[Cap]):
|
||||
"""
|
||||
Add a ceph user with its defined capabilities.
|
||||
:param user_entity: Entity to change
|
||||
:param capabilities: List of capabilities to add to user_entity
|
||||
"""
|
||||
# Caps are represented as a vector in mon auth add commands.
|
||||
# Look at AuthMonitor.cc::valid_caps for reference.
|
||||
caps = []
|
||||
for cap in capabilities:
|
||||
caps.append(cap['entity'])
|
||||
caps.append(cap['cap'])
|
||||
|
||||
logger.debug("Sending command 'auth add' of entity '%s' with caps '%s'",
|
||||
user_entity, str(caps))
|
||||
try:
|
||||
CephService.send_command('mon', 'auth add', entity=user_entity, caps=caps)
|
||||
except SendCommandError as ex:
|
||||
msg = f'{ex} in command {ex.prefix}'
|
||||
if ex.errno == -EINVAL:
|
||||
raise DashboardException(msg, code=400)
|
||||
raise DashboardException(msg, code=500)
|
||||
return f"Successfully created user '{user_entity}'"
|
||||
|
||||
|
||||
create_cap_container = ArrayHorizontalContainer('Capabilities', 'capabilities',
|
||||
label_html_class='hidden cd-header mt-1', fields=[
|
||||
FormField('Entity', 'entity',
|
||||
field_type=str, html_class='mr-3'),
|
||||
FormField('Entity Capabilities',
|
||||
'cap', field_type=str)
|
||||
])
|
||||
create_container = VerticalContainer('Create User', 'create_user',
|
||||
html_class='d-none', fields=[
|
||||
FormField('User entity', 'user_entity',
|
||||
field_type=str),
|
||||
create_cap_container,
|
||||
])
|
||||
|
||||
create_form = Form(path='/cluster/user/create',
|
||||
root_container=create_container, action='Create User')
|
||||
|
||||
|
||||
@CRUDEndpoint(
|
||||
router=APIRouter('/cluster/user', Scope.CONFIG_OPT),
|
||||
doc=APIDoc("Get Ceph Users", "Cluster"),
|
||||
set_column={"caps": {"cellTemplate": "badgeDict"}},
|
||||
actions=[
|
||||
TableAction(name='create', permission='create', icon=Icon.add.value,
|
||||
routerLink='/cluster/user/create')
|
||||
],
|
||||
permissions=[Scope.CONFIG_OPT],
|
||||
forms=[create_form],
|
||||
get_all=CRUDCollectionMethod(
|
||||
func=lambda **_: CephService.send_command('mon', 'auth ls')["auth_dump"],
|
||||
func=CephUserEndpoints.user_list,
|
||||
doc=EndpointDoc("Get Ceph Users")
|
||||
),
|
||||
create=CRUDCollectionMethod(
|
||||
func=CephUserEndpoints.user_create,
|
||||
doc=EndpointDoc("Create Ceph User")
|
||||
)
|
||||
)
|
||||
class CephUser(NamedTuple):
|
||||
entity: str
|
||||
caps: CephUserCaps
|
||||
caps: List[CephUserCaps]
|
||||
key: SecretStr
|
||||
|
48
src/pybind/mgr/dashboard/frontend/package-lock.json
generated
48
src/pybind/mgr/dashboard/frontend/package-lock.json
generated
@ -10,6 +10,44 @@
|
||||
"integrity": "sha512-20Pk2Z98fbPLkECcrZSJszKos/OgtvJJR3NcbVfgCJ6EQjDNzW2P1BKqImOz3tJ952dvO2DWEhcLhQ1Wz1e9ng==",
|
||||
"dev": true
|
||||
},
|
||||
"@ajsf/bootstrap4": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ajsf/bootstrap4/-/bootstrap4-0.7.0.tgz",
|
||||
"integrity": "sha512-wn6wIQeWknmn/t96XZgihfFq/jjr9GkV9P5dHEU+i9wQbxPNL1MS+x4tLWj9LH3Mx5RiC0Dr4gPgbkDd/bzLxg==",
|
||||
"requires": {
|
||||
"@ajsf/core": "~0.7.0",
|
||||
"lodash-es": "~4.17.21",
|
||||
"tslib": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"@ajsf/core": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@ajsf/core/-/core-0.7.0.tgz",
|
||||
"integrity": "sha512-mysKftZAxT0bHYoia7LzbSinK7Z55wINS63zeK/rqSs9r2dF01Vxtzlx2ITViiok3TQ0UV+1OYce/piozEf4aw==",
|
||||
"requires": {
|
||||
"ajv": "^6.10.0",
|
||||
"lodash-es": "~4.17.21",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
}
|
||||
},
|
||||
"json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@ampproject/remapping": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
|
||||
@ -11011,8 +11049,7 @@
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
},
|
||||
"fast-glob": {
|
||||
"version": "3.2.12",
|
||||
@ -11046,8 +11083,7 @@
|
||||
"fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
|
||||
},
|
||||
"fast-levenshtein": {
|
||||
"version": "2.0.6",
|
||||
@ -24050,7 +24086,6 @@
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"punycode": "^2.1.0"
|
||||
},
|
||||
@ -24058,8 +24093,7 @@
|
||||
"punycode": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
||||
"dev": true
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -44,6 +44,8 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ajsf/bootstrap4": "0.7.0",
|
||||
"@ajsf/core": "0.7.0",
|
||||
"@angular/animations": "13.3.11",
|
||||
"@angular/common": "13.3.11",
|
||||
"@angular/compiler": "13.3.11",
|
||||
|
@ -37,6 +37,7 @@ import { LoginLayoutComponent } from './core/layouts/login-layout/login-layout.c
|
||||
import { WorkbenchLayoutComponent } from './core/layouts/workbench-layout/workbench-layout.component';
|
||||
import { ApiDocsComponent } from './core/navigation/api-docs/api-docs.component';
|
||||
import { ActionLabels, URLVerbs } from './shared/constants/app.constants';
|
||||
import { CrudFormComponent } from './shared/datatable/crud-table/crud-form/crud-form.component';
|
||||
import { CRUDTableComponent } from './shared/datatable/crud-table/crud-table.component';
|
||||
import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs';
|
||||
import { AuthGuardService } from './shared/services/auth-guard.service';
|
||||
@ -124,6 +125,14 @@ const routes: Routes = [
|
||||
resource: 'api.cluster.user@1.0'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'cluster/user/create',
|
||||
component: CrudFormComponent,
|
||||
data: {
|
||||
breadcrumbs: 'Cluster/Users',
|
||||
resource: 'api.cluster.user@1.0'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'monitor',
|
||||
component: MonitorComponent,
|
||||
|
@ -54,7 +54,7 @@ export class SubmitButtonComponent implements OnInit {
|
||||
constructor(private elRef: ElementRef) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.form.statusChanges.subscribe(() => {
|
||||
this.form?.statusChanges.subscribe(() => {
|
||||
if (_.has(this.form.errors, 'cdSubmitButton')) {
|
||||
this.loading = false;
|
||||
_.unset(this.form.errors, 'cdSubmitButton');
|
||||
|
@ -0,0 +1,20 @@
|
||||
<div class="cd-col-form">
|
||||
<div class="card pb-0">
|
||||
<div i18n="form title"
|
||||
class="card-header">{{ title }}</div>
|
||||
|
||||
<div class="card-body position-relative">
|
||||
<json-schema-form
|
||||
*ngIf="controlSchema && uiSchema"
|
||||
[schema]="controlSchema"
|
||||
[layout]="uiSchema"
|
||||
[data]="data"
|
||||
[widgets]="widgets"
|
||||
(onSubmit)="submit($event)"
|
||||
[options]="formOptions"
|
||||
framework="bootstrap-4">
|
||||
</json-schema-form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,22 @@
|
||||
@use './src/styles/vendor/variables' as vv;
|
||||
|
||||
::ng-deep json-schema-form {
|
||||
label.control-label.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-group.schema-form-submit p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
legend {
|
||||
font-weight: 100 !important;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
border: 1px solid rgba(0, 0, 0, 0.125);
|
||||
left: -1px;
|
||||
width: -webkit-fill-available;
|
||||
width: -moz-available;
|
||||
}
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { ToastrModule, ToastrService } from 'ngx-toastr';
|
||||
import { configureTestBed } from '~/testing/unit-test-helper';
|
||||
import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
|
||||
import { CrudFormComponent } from './crud-form.component';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
|
||||
describe('CrudFormComponent', () => {
|
||||
let component: CrudFormComponent;
|
||||
let fixture: ComponentFixture<CrudFormComponent>;
|
||||
const toastFakeService = {
|
||||
error: () => true,
|
||||
info: () => true,
|
||||
success: () => true
|
||||
};
|
||||
|
||||
configureTestBed({
|
||||
imports: [ToastrModule.forRoot(), RouterTestingModule, HttpClientTestingModule],
|
||||
providers: [
|
||||
{ provide: ToastrService, useValue: toastFakeService },
|
||||
{ provide: CdDatePipe, useValue: { transform: (d: any) => d } }
|
||||
]
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [CrudFormComponent]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CrudFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,65 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { DataGatewayService } from '~/app/shared/services/data-gateway.service';
|
||||
import { BackButtonComponent } from '~/app/shared/components/back-button/back-button.component';
|
||||
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
|
||||
import { FinishedTask } from '~/app/shared/models/finished-task';
|
||||
import { Location } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'cd-crud-form',
|
||||
templateUrl: './crud-form.component.html',
|
||||
styleUrls: ['./crud-form.component.scss']
|
||||
})
|
||||
export class CrudFormComponent implements OnInit {
|
||||
uiSchema: any;
|
||||
controlSchema: any;
|
||||
data: any;
|
||||
widgets: any = {
|
||||
cancel: BackButtonComponent
|
||||
};
|
||||
resource: string;
|
||||
title: string;
|
||||
|
||||
formOptions = {
|
||||
defautWidgetOptions: {
|
||||
validationMessages: {
|
||||
required: 'This field is required'
|
||||
}
|
||||
}
|
||||
};
|
||||
constructor(
|
||||
private dataGatewayService: DataGatewayService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private taskWrapper: TaskWrapperService,
|
||||
private location: Location
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.activatedRoute.data.subscribe((data: any) => {
|
||||
this.resource = data.resource;
|
||||
this.dataGatewayService.list(`ui-${this.resource}`).subscribe((response: any) => {
|
||||
this.title = response.forms[0].control_schema.title;
|
||||
this.uiSchema = response.forms[0].ui_schema;
|
||||
this.controlSchema = response.forms[0].control_schema;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
submit(data: any) {
|
||||
if (data) {
|
||||
this.taskWrapper
|
||||
.wrapTaskAroundCall({
|
||||
task: new FinishedTask('ceph-user/create', {
|
||||
user_entity: data.user_entity
|
||||
}),
|
||||
call: this.dataGatewayService.create(this.resource, data)
|
||||
})
|
||||
.subscribe({
|
||||
complete: () => {
|
||||
this.location.back();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,19 @@
|
||||
<ng-container *ngIf="meta">
|
||||
<cd-table
|
||||
[data]="data$ | async"
|
||||
[columns]="meta.table.columns"
|
||||
[columnMode]="meta.table.columnMode"
|
||||
[toolHeader]="meta.table.toolHeader"
|
||||
></cd-table>
|
||||
[data]="data$ | async"
|
||||
[columns]="meta.table.columns"
|
||||
[columnMode]="meta.table.columnMode"
|
||||
[toolHeader]="meta.table.toolHeader">
|
||||
<div class="table-actions btn-toolbar">
|
||||
<cd-table-actions [permission]="permission"
|
||||
[selection]="selection"
|
||||
class="btn-group"
|
||||
id="crud-table-actions"
|
||||
[tableActions]="meta.actions">
|
||||
</cd-table-actions>
|
||||
</div>
|
||||
|
||||
</cd-table>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #badgeDictTpl
|
||||
|
@ -7,6 +7,9 @@ import { Observable } from 'rxjs';
|
||||
import { CrudMetadata } from '~/app/shared/models/crud-table-metadata';
|
||||
import { DataGatewayService } from '~/app/shared/services/data-gateway.service';
|
||||
import { TimerService } from '~/app/shared/services/timer.service';
|
||||
import { CdTableSelection } from '../../models/cd-table-selection';
|
||||
import { Permission, Permissions } from '../../models/permissions';
|
||||
import { AuthStorageService } from '../../services/auth-storage.service';
|
||||
|
||||
@Component({
|
||||
selector: 'cd-crud-table',
|
||||
@ -20,28 +23,45 @@ export class CRUDTableComponent implements OnInit {
|
||||
data$: Observable<any>;
|
||||
meta$: Observable<CrudMetadata>;
|
||||
meta: CrudMetadata;
|
||||
permissions: Permissions;
|
||||
permission: Permission;
|
||||
selection = new CdTableSelection();
|
||||
|
||||
constructor(
|
||||
private authStorageService: AuthStorageService,
|
||||
private timerService: TimerService,
|
||||
private dataGatewayService: DataGatewayService,
|
||||
private activatedRoute: ActivatedRoute
|
||||
) {}
|
||||
) {
|
||||
this.permissions = this.authStorageService.getPermissions();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
/* The following should be simplified with a wrapper that
|
||||
converts .data to @Input args. For example:
|
||||
https://medium.com/@andrewcherepovskiy/passing-route-params-into-angular-components-input-properties-fc85c34c9aca
|
||||
*/
|
||||
this.activatedRoute.data.subscribe((data) => {
|
||||
this.activatedRoute.data.subscribe((data: any) => {
|
||||
const resource: string = data.resource;
|
||||
this.dataGatewayService
|
||||
.list(`ui-${resource}`)
|
||||
.subscribe((response) => this.processMeta(response));
|
||||
.subscribe((response: any) => this.processMeta(response));
|
||||
this.data$ = this.timerService.get(() => this.dataGatewayService.list(resource));
|
||||
});
|
||||
}
|
||||
|
||||
processMeta(meta: CrudMetadata) {
|
||||
const toCamelCase = (test: string) =>
|
||||
test
|
||||
.split('-')
|
||||
.reduce(
|
||||
(res: string, word: string, i: number) =>
|
||||
i === 0
|
||||
? word.toLowerCase()
|
||||
: `${res}${word.charAt(0).toUpperCase()}${word.substr(1).toLowerCase()}`,
|
||||
''
|
||||
);
|
||||
this.permission = this.permissions[toCamelCase(meta.permissions[0])];
|
||||
this.meta = meta;
|
||||
const templates = {
|
||||
badgeDict: this.badgeDictTpl
|
||||
|
@ -14,6 +14,8 @@ import { TableActionsComponent } from './table-actions/table-actions.component';
|
||||
import { TableKeyValueComponent } from './table-key-value/table-key-value.component';
|
||||
import { TablePaginationComponent } from './table-pagination/table-pagination.component';
|
||||
import { TableComponent } from './table/table.component';
|
||||
import { Bootstrap4FrameworkModule } from '@ajsf/bootstrap4';
|
||||
import { CrudFormComponent } from './crud-table/crud-form/crud-form.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@ -25,14 +27,16 @@ import { TableComponent } from './table/table.component';
|
||||
NgbTooltipModule,
|
||||
PipesModule,
|
||||
ComponentsModule,
|
||||
RouterModule
|
||||
RouterModule,
|
||||
Bootstrap4FrameworkModule
|
||||
],
|
||||
declarations: [
|
||||
TableComponent,
|
||||
TableKeyValueComponent,
|
||||
TableActionsComponent,
|
||||
CRUDTableComponent,
|
||||
TablePaginationComponent
|
||||
TablePaginationComponent,
|
||||
CrudFormComponent
|
||||
],
|
||||
exports: [
|
||||
TableComponent,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
|
||||
import { CdTableAction } from './cd-table-action';
|
||||
|
||||
class Table {
|
||||
columns: CdTableColumn[];
|
||||
@ -8,4 +9,7 @@ class Table {
|
||||
|
||||
export class CrudMetadata {
|
||||
table: Table;
|
||||
permissions: string[];
|
||||
actions: CdTableAction[];
|
||||
forms: any;
|
||||
}
|
||||
|
@ -12,16 +12,31 @@ export class DataGatewayService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
list(dataPath: string): Observable<any> {
|
||||
if (this.cache[dataPath] === undefined) {
|
||||
const cacheable = this.getCacheable(dataPath, 'get');
|
||||
if (this.cache[cacheable] === undefined) {
|
||||
const match = dataPath.match(/(?<url>[^@]+)(?:@(?<version>.+))?/);
|
||||
const url = match.groups.url.split('.').join('/');
|
||||
const version = match.groups.version || '1.0';
|
||||
|
||||
this.cache[dataPath] = this.http.get<any>(url, {
|
||||
this.cache[cacheable] = this.http.get<any>(url, {
|
||||
headers: { Accept: `application/vnd.ceph.api.v${version}+json` }
|
||||
});
|
||||
}
|
||||
|
||||
return this.cache[dataPath];
|
||||
return this.cache[cacheable];
|
||||
}
|
||||
|
||||
create(dataPath: string, data: any): Observable<any> {
|
||||
const match = dataPath.match(/(?<url>[^@]+)(?:@(?<version>.+))?/);
|
||||
const url = match.groups.url.split('.').join('/');
|
||||
const version = match.groups.version || '1.0';
|
||||
|
||||
return this.http.post<any>(url, data, {
|
||||
headers: { Accept: `application/vnd.ceph.api.v${version}+json` }
|
||||
});
|
||||
}
|
||||
|
||||
getCacheable(dataPath: string, method: string) {
|
||||
return dataPath + method;
|
||||
}
|
||||
}
|
||||
|
@ -339,6 +339,9 @@ export class TaskMessageService {
|
||||
),
|
||||
'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
|
||||
this.service(metadata)
|
||||
),
|
||||
'ceph-users/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
|
||||
this.cephUser(metadata)
|
||||
)
|
||||
};
|
||||
|
||||
@ -384,6 +387,10 @@ export class TaskMessageService {
|
||||
return $localize`Service '${metadata.service_name}'`;
|
||||
}
|
||||
|
||||
cephUser(metadata: any) {
|
||||
return $localize`Ceph User '${metadata.user_entity}'`;
|
||||
}
|
||||
|
||||
_getTaskTitle(task: Task) {
|
||||
if (task.name && task.name.startsWith('progress/')) {
|
||||
// we don't fill the failure string because, at least for now, all
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Bootstrap4FrameworkModule } from '@ajsf/bootstrap4';
|
||||
|
||||
import { CssHelper } from '~/app/shared/classes/css-helper';
|
||||
import { ComponentsModule } from './components/components.module';
|
||||
@ -11,7 +12,14 @@ import { AuthStorageService } from './services/auth-storage.service';
|
||||
import { FormatterService } from './services/formatter.service';
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, PipesModule, ComponentsModule, DataTableModule, DirectivesModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
PipesModule,
|
||||
ComponentsModule,
|
||||
DataTableModule,
|
||||
DirectivesModule,
|
||||
Bootstrap4FrameworkModule
|
||||
],
|
||||
declarations: [],
|
||||
exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule],
|
||||
providers: [AuthStorageService, AuthGuardService, FormatterService, CssHelper]
|
||||
|
@ -217,3 +217,17 @@ a.btn-light {
|
||||
.badge-dark {
|
||||
@extend .badge, .bg-dark;
|
||||
}
|
||||
|
||||
json-schema-form {
|
||||
.help-block {
|
||||
@extend .invalid-feedback;
|
||||
}
|
||||
|
||||
.ng-touched.ng-invalid {
|
||||
@extend .is-invalid;
|
||||
}
|
||||
|
||||
.ng-touched.ng-valid {
|
||||
@extend .is-valid;
|
||||
}
|
||||
}
|
||||
|
@ -80,6 +80,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
@extend .btn-light;
|
||||
}
|
||||
|
||||
.btn-primary .badge {
|
||||
background-color: vv.$gray-200;
|
||||
color: vv.$primary;
|
||||
|
@ -2147,6 +2147,8 @@ paths:
|
||||
- Cluster
|
||||
/api/cluster/user:
|
||||
get:
|
||||
description: "\n Get list of ceph users and its respective data\n \
|
||||
\ "
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
@ -2168,6 +2170,49 @@ paths:
|
||||
summary: Get Ceph Users
|
||||
tags:
|
||||
- Cluster
|
||||
post:
|
||||
description: "\n Add a ceph user with its defined capabilities.\n \
|
||||
\ :param user_entity: Entity to change\n :param capabilities: List\
|
||||
\ of capabilities to add to user_entity\n "
|
||||
parameters: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
capabilities:
|
||||
type: string
|
||||
user_entity:
|
||||
type: string
|
||||
required:
|
||||
- user_entity
|
||||
- capabilities
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
content:
|
||||
application/vnd.ceph.api.v1.0+json:
|
||||
type: object
|
||||
description: Resource created.
|
||||
'202':
|
||||
content:
|
||||
application/vnd.ceph.api.v1.0+json:
|
||||
type: object
|
||||
description: Operation is still executing. Please check the task queue.
|
||||
'400':
|
||||
description: Operation exception. Please check the response body for details.
|
||||
'401':
|
||||
description: Unauthenticated access. Please login first.
|
||||
'403':
|
||||
description: Unauthorized access. Please check your permissions.
|
||||
'500':
|
||||
description: Unexpected error. Please check the response body for the stack
|
||||
trace.
|
||||
security:
|
||||
- jwt: []
|
||||
summary: Create Ceph User
|
||||
tags:
|
||||
- Cluster
|
||||
/api/cluster_conf:
|
||||
get:
|
||||
parameters: []
|
||||
|
@ -8,3 +8,4 @@ rstcheck==3.3.1
|
||||
autopep8==1.5.7
|
||||
pyfakefs==4.5.0
|
||||
isort==5.5.3
|
||||
jsonschema==4.16.0
|
||||
|
@ -1,3 +1,4 @@
|
||||
pytest-cov
|
||||
pytest-instafail
|
||||
pyfakefs==4.5.0
|
||||
jsonschema==4.16.0
|
||||
|
@ -11,3 +11,4 @@ pytest
|
||||
pyyaml
|
||||
natsort
|
||||
setuptools
|
||||
jsonpatch
|
||||
|
@ -1,6 +1,8 @@
|
||||
import unittest.mock as mock
|
||||
|
||||
from ..controllers.ceph_users import CephUser
|
||||
from jsonschema import validate
|
||||
|
||||
from ..controllers.ceph_users import CephUser, create_form
|
||||
from ..tests import ControllerTestCase
|
||||
|
||||
auth_dump_mock = {"auth_dump": [
|
||||
@ -41,3 +43,8 @@ class CephUsersControllerTestCase(ControllerTestCase):
|
||||
"key": "***********"
|
||||
}
|
||||
])
|
||||
|
||||
def test_create_form(self):
|
||||
form_dict = create_form.to_dict()
|
||||
schema = {'schema': form_dict['control_schema'], 'layout': form_dict['ui_schema']}
|
||||
validate(instance={'user_entity': 'foo', 'capabilities': []}, schema=schema['schema'])
|
||||
|
@ -4,8 +4,11 @@ import json
|
||||
from typing import NamedTuple
|
||||
|
||||
import pytest
|
||||
from jsonschema import validate
|
||||
|
||||
from ..controllers._crud import SecretStr, serialize
|
||||
from ..controllers._crud import ArrayHorizontalContainer, \
|
||||
ArrayVerticalContainer, Form, FormField, HorizontalContainer, SecretStr, \
|
||||
VerticalContainer, serialize
|
||||
|
||||
|
||||
def assertObjectEquals(a, b):
|
||||
@ -26,10 +29,41 @@ class NamedTupleSecretMock(NamedTuple):
|
||||
@pytest.mark.parametrize("inp,out", [
|
||||
(["foo", "var"], ["foo", "var"]),
|
||||
(NamedTupleMock(1, "test"), {"foo": 1, "var": "test"}),
|
||||
(NamedTupleSecretMock(1, "test", "supposethisisakey"), {"foo": 1, "var": "test",
|
||||
(NamedTupleSecretMock(1, "test", "imaginethisisakey"), {"foo": 1, "var": "test",
|
||||
"key": "***********"}),
|
||||
((1, 2, 3), [1, 2, 3]),
|
||||
(set((1, 2, 3)), [1, 2, 3]),
|
||||
])
|
||||
def test_serialize(inp, out):
|
||||
assertObjectEquals(serialize(inp), out)
|
||||
|
||||
|
||||
def test_schema():
|
||||
form = Form(path='/cluster/user/create',
|
||||
root_container=VerticalContainer('Create user', key='create_user', fields=[
|
||||
FormField('User entity', key='user_entity', field_type=str),
|
||||
ArrayHorizontalContainer('Capabilities', key='caps', fields=[
|
||||
FormField('left', field_type=str, key='left',
|
||||
html_class='cd-col-form-input'),
|
||||
FormField('right', key='right', field_type=str)
|
||||
]),
|
||||
ArrayVerticalContainer('ah', key='ah', fields=[
|
||||
FormField('top', key='top', field_type=str, label_html_class='d-none'),
|
||||
FormField('bottom', key='bottom', field_type=str)
|
||||
]),
|
||||
HorizontalContainer('oh', key='oh', fields=[
|
||||
FormField('left', key='left', field_type=str, label_html_class='d-none'),
|
||||
FormField('right', key='right', field_type=str)
|
||||
]),
|
||||
VerticalContainer('ov', key='ov', fields=[
|
||||
FormField('top', key='top', field_type=str, label_html_class='d-none'),
|
||||
FormField('bottom', key='bottom', field_type=bool)
|
||||
]),
|
||||
]))
|
||||
form_dict = form.to_dict()
|
||||
schema = {'schema': form_dict['control_schema'], 'layout': form_dict['ui_schema']}
|
||||
validate(instance={'user_entity': 'foo',
|
||||
'caps': [{'left': 'foo', 'right': 'foo2'}],
|
||||
'ah': [{'top': 'foo', 'bottom': 'foo2'}],
|
||||
'oh': {'left': 'foo', 'right': 'foo2'},
|
||||
'ov': {'top': 'foo', 'bottom': True}}, schema=schema['schema'])
|
||||
|
Loading…
Reference in New Issue
Block a user