+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
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)}
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
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):
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
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):
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):
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
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,
'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)
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:
-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):
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
"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",
"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",
"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",
"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"
},
},
"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",
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';
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,
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');
--- /dev/null
+<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>
--- /dev/null
+@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;
+ }
+}
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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();
+ }
+ });
+ }
+ }
+}
<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
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',
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
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: [
NgbTooltipModule,
PipesModule,
ComponentsModule,
- RouterModule
+ RouterModule,
+ Bootstrap4FrameworkModule
+ ],
+ declarations: [
+ TableComponent,
+ TableKeyValueComponent,
+ TableActionsComponent,
+ CRUDTableComponent,
+ TablePaginationComponent,
+ CrudFormComponent
],
- declarations: [TableComponent, TableKeyValueComponent, TableActionsComponent, CRUDTableComponent],
exports: [
TableComponent,
NgxDatatableModule,
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableAction } from './cd-table-action';
class Table {
columns: CdTableColumn[];
export class CrudMetadata {
table: Table;
+ permissions: string[];
+ actions: CdTableAction[];
+ forms: any;
}
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;
}
}
),
'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
this.service(metadata)
+ ),
+ 'ceph-users/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+ this.cephUser(metadata)
)
};
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
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';
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]
.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;
+ }
+}
}
}
+.btn-default {
+ @extend .btn-light;
+}
+
.btn-primary .badge {
background-color: vv.$gray-200;
color: vv.$primary;
- Cluster
/api/cluster/user:
get:
+ description: "\n Get list of ceph users and its respective data\n \
+ \ "
parameters: []
responses:
'200':
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: []
autopep8==1.5.7
pyfakefs==4.5.0
isort==5.5.3
+jsonschema==4.16.0
pytest-cov
pytest-instafail
pyfakefs==4.5.0
+jsonschema==4.16.0
pyyaml
natsort
setuptools
+jsonpatch
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": [
"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'])
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):
@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'])