"""
Base Dazzler user administration page.
Implementations:
- :py:class:`~.dazzler.contrib.PostgresUserAdminPage`
"""
import traceback
import typing
import precept.events
from aiohttp import web
from dazzler.events import DAZZLER_SETUP
from dazzler.presets import PresetColor, PresetSize
from dazzler.system import Page, CallContext, Trigger, Target, transforms as t
def _get_trigger_meta(identity: str):
return identity.split('$')[-1]
[docs]class AdminUser(typing.NamedTuple):
username: str
active: bool
roles: typing.List[str]
[docs]class AdminRole(typing.NamedTuple):
role_name: str
description: typing.Optional[str]
async def _confirm_delete_user(ctx: CallContext):
username = _get_trigger_meta(ctx.trigger.identity)
await ctx.set_aspect('delete-username', text=username)
await ctx.set_aspect('confirm-delete-user-modal', active=True)
[docs]class UserAdminPage(Page):
"""
:type app: dazzler.Dazzler
"""
[docs] def __init__(
self,
app,
name='user-admin',
layout=None,
authorizations=('admin',),
users_per_page=10,
users_page_displayed=5,
packages=None,
url='/auth/admin',
**kwargs
):
packages = packages or [
'dazzler_core', 'dazzler_extra', 'dazzler_icons'
]
super().__init__(
name, layout,
require_login=True,
authorizations=authorizations,
packages=packages,
url=url,
**kwargs
)
self.app = app
self.users_per_page = users_per_page
self.users_page_displayed = users_page_displayed
# Import here so it's not on all pages if not required.
from dazzler.components import core, extra, icons
self._core = core
self._extra = extra
self._icons = icons
# Ties & Transforms
# Enabled create role button
self.tie('value@new-role-name', 'disabled@new-role-button').transform(
t.Length().t(t.Lesser(1))
)
# Updates options on role creation/deletion
self.tie(
Trigger(aspect='data', identity='roles-data', skip_initial=True),
Target(aspect='options', identity=r'user-roles\$', regex=True),
)
# Open the user filters options
self.tie(
'clicks@user-filter-toggle',
'hidden@user-filters',
).transform(t.Modulus(2)).t(t.Equals(0))
# Set the user filters data.
self.tie(
'clicks@apply-user-filters-btn',
'data@user-filters-data'
).transform(
t.AspectValue('data@user-filters-data')
.t(t.Set('username', t.Target('value@username-filter')))
.t(t.Set('user_roles', t.Target('value@user-roles-filter')))
.t(t.Set('active', t.Target('checked@user-active-filter')))
)
# Bindings
self.call(
Trigger(
aspect='checked',
identity=r'active\$',
regex=True,
skip_initial=True,
)
)(self.on_toggle_user_active)
self.call(
Trigger(
aspect='value',
identity=r'user-roles\$',
regex=True,
skip_initial=True
)
)(self.on_change_user_roles)
self.call(
Trigger(
aspect='clicks',
identity=r'delete\$',
regex=True
)
)(_confirm_delete_user)
self.call(
'clicks@confirm-delete-user-button',
'text@delete-username'
)(self.on_delete_user)
self.call(
'clicks@new-role-button',
'value@new-role-name',
'value@new-role-description',
'data@roles-data'
)(self.on_create_role)
self.call(
Trigger(
aspect='clicks',
identity=r'remove-role\$',
regex=True,
skip_initial=True,
),
'data@role-delete-offset',
'data@roles-data',
)(self.on_role_delete)
self.call(
Trigger(
aspect='start_offset',
identity='users-pager',
skip_initial=True,
),
'items_per_page@users-pager',
'data@roles-data',
'data@user-filters-data',
)(self.on_change_users_page)
self.call(
Trigger(
aspect='data',
identity='user-filters-data',
skip_initial=True,
),
'items_per_page@users-pager',
'data@roles-data',
)(self.on_apply_user_filters)
self.call(
Trigger(
aspect='value',
identity=r'role-description\$',
regex=True,
skip_initial=True,
)
)(self.on_description_change)
self.layout = self._layout_handler
app.events.subscribe(DAZZLER_SETUP, self.setup)
[docs] async def setup(self, event: precept.events.Event):
pass
# pylint: disable=unused-argument
async def _layout_handler(self, request: web.Request):
users = await self.get_users(0, self.users_per_page)
roles = await self.get_roles()
user_count = await self.get_user_count()
return await self.get_layout(users, user_count, roles)
[docs] async def get_layout(
self,
users: typing.List[AdminUser],
user_count: int,
roles: typing.List[AdminRole],
):
core = self._core
extra = self._extra
icons = self._icons
role_names = [role.role_name for role in roles]
return core.Box([
icons.IconLoader([]),
icons.FoundIconPack(),
core.Store(data=role_names, identity='roles-data'),
core.Store(data={}, identity='user-filters-data'),
core.Modal(
core.Container([
core.Box([
core.Text('Are you sure you want to delete '),
core.Text(
identity='delete-username',
font_weight='bold',
preset_color=PresetColor.DANGER_DARK
),
core.Text(' ?')
], padding='1rem 0'),
core.Box(core.Text('Username will be up for grabs!'))
]),
header=core.Box([
icons.Icon('fi-skull'),
core.Text(' Confirm user deletion')
], preset_size=PresetSize.LARGE, padding='1rem 0'),
footer=core.Box(
core.Button(
[icons.Icon('fi-trash'), core.Text(' Confirm')],
preset=PresetColor.DANGER,
rounded=True,
size=PresetSize.LARGER,
bordered=False,
identity='confirm-delete-user-button'
),
justify='flex-end',
),
active=False,
identity='confirm-delete-user-modal'
),
core.Container(
[
icons.Icon('fi-wrench'),
core.Text(' User Administration')
],
style={},
font_size=22,
padding=8,
margin_top='2rem',
),
core.ViewPort(
active='users',
views={
'users': core.Box([
core.Grid([
core.Box([
icons.Icon('fi-torso'),
core.Text(' Username')],
margin_left='0.25rem',
),
core.Box([
icons.Icon('fi-lock'),
core.Text(' Active')
], justify='center'),
core.Box([
icons.Icon('fi-shield'),
core.Text(' User Roles')],
justify='center'),
core.Box(
[
extra.PopUp(
icons.Icon('fi-widget'),
'Filters',
content_style={'background': 'white'}
)
],
justify='flex-end',
margin_right='1rem',
identity='user-filter-toggle'
),
],
width='100%',
columns=4,
equal_cell_width=True,
style={
'userSelect': 'none',
'fontWeight': 'bold',
'fontSize': 18,
'padding': '0.5rem 0',
},
preset_background=PresetColor.NEUTRAL_DARK,
),
core.Grid([
core.Box(
core.Input(
placeholder='Filter username',
style={'width': '100%', 'height': '80%'},
identity='username-filter'
),
width='100%',
height='100%',
align_items='center',
margin_left='0.25rem',
),
core.Box(
core.Checkbox(
indeterminate=True,
preset_size=PresetSize.LARGE,
identity='user-active-filter',
click_indeterminate=True,
),
justify='center',
align_items='center',
height='100%'
),
core.Box(
core.Dropdown(
options=role_names,
identity='user-roles-filter',
multi=True,
style={'width': '100%', 'margin': 2},
),
width='100%'
),
core.Box(
core.Button(
'Search',
identity='apply-user-filters-btn',
preset=PresetColor.PRIMARY,
size=PresetSize.LARGE,
bordered=False,
rounded=True,
),
justify='right'
)
],
width='100%',
columns=4,
equal_cell_width=True,
preset_background=PresetColor.NEUTRAL_DARK,
identity='user-filters',
hidden=True,
),
core.ListBox(
[
self.get_user_row(user, role_names)
for user in users
],
identity='users-listbox',
flex_grow=1,
bordered=True,
style={'borderBottom': 'none'}
),
core.Box([
extra.Pager(
total_items=user_count,
items_per_page=self.users_per_page,
pages_displayed=self.users_page_displayed,
identity='users-pager',
current_page=1,
next_label=icons.Icon('fi-arrow-right'),
previous_label=icons.Icon('fi-arrow-left'),
)
],
centered=True,
padding='0.5rem 0',
preset_background=PresetColor.NEUTRAL,
border_radius='0 0 5px 5px'
)
], column=True),
'roles': core.Container([
core.Grid(
[
core.Box(core.Input(
placeholder='Role name',
identity='new-role-name',
style={'width': '90%'}
),
justify='flex-start',
width='100%',
height='80%',
padding_left='0.25rem'
),
core.Box(core.Input(
placeholder='Description',
identity='new-role-description',
style={'width': '100%'}
),
height='80%',
width='100%',
),
core.Box(core.Button(
'Create',
identity='new-role-button',
preset=PresetColor.PRIMARY,
bordered=False,
rounded=True,
size=PresetSize.LARGE,
disabled=True,
),
justify='flex-end',
width='100%',
padding_right='0.25rem'
)
],
columns=3,
equal_cell_width=True,
center_cells=True,
preset_background=PresetColor.NEUTRAL_DARK,
),
core.ListBox(
[
self.get_role_row(role)
for role in roles
],
identity='role-listbox',
bordered=True,
),
]),
},
tabbed=True,
tab_labels={
'users': core.Box(
[icons.Icon('fi-torsos-all'), core.Text(' Users')],
font_weight='bold',
font_size=20,
padding=8,
justify='center'
),
'roles': core.Box(
[icons.Icon('fi-shield'), core.Text(' Roles')],
font_weight='bold',
font_size=20,
padding=8,
justify='center',
),
},
style={'width': '80%'}
),
core.ListBox(identity='toast-listbox'),
], identity='admin-page', column=True, align_items='center')
[docs] def get_role_row(self, role: AdminRole):
core = self._core
icons = self._icons
return core.Grid(
[
core.Box(core.Text(role.role_name),
justify='flex-start',
width='100%', margin_left='0.25rem'),
core.Box(core.Input(
value=role.description,
identity=f'role-description${role.role_name}',
style={'width': '100%'}
), width='100%', height='80%'),
core.Box(core.Button(
icons.Icon('fi-trash'),
circle=True,
bordered=False,
identity=f'remove-role${role.role_name}',
style={'width': '2rem', 'height': '2rem'},
preset=PresetColor.DANGER,
size=PresetSize.LARGE,
), width='100%', justify='flex-end', margin_right='0.25rem'),
],
columns=3,
equal_cell_width=True,
center_cells=True,
class_name='admin-row',
identity=f'role-row-{role.role_name}',
style={'borderBottom': '#e5e5ea solid 1px'},
)
[docs] def get_user_row(self, user: AdminUser, roles: typing.List[str]):
core = self._core
icons = self._icons
return core.Grid([
core.Box(
user.username,
width='100%',
margin_left=4,
),
core.Checkbox(
checked=user.active,
identity=f'active${user.username}',
preset_size=PresetSize.LARGE,
),
core.Dropdown(
options=roles,
value=user.roles,
multi=True,
style={'width': '100%', 'margin': 2},
identity=f'user-roles${user.username}'
),
core.Box([
core.Button([
icons.Icon('fi-trash')
],
circle=True,
identity=f'delete${user.username}',
bordered=False,
style={
'width': '2rem',
'height': '2rem'
},
preset=PresetColor.DANGER,
size=PresetSize.LARGE,
),
],
align_items='center',
justify='flex-end',
width='100%',
margin_right=4
),
],
columns=4,
equal_cell_width=True,
center_cells=True,
style={'borderBottom': '#e5e5ea solid 1px'},
identity=f'user-row${user.username}',
)
# Binding handlers
[docs] async def on_toggle_user_active(self, ctx: CallContext):
username = _get_trigger_meta(ctx.trigger.identity)
await self.toggle_active_user(username, ctx.trigger.value)
[docs] async def on_change_user_roles(self, ctx: CallContext):
username = _get_trigger_meta(ctx.trigger.identity)
await self.change_user_roles(username, ctx.trigger.value)
[docs] async def on_delete_user(self, ctx: CallContext):
username = ctx.states['delete-username']['text']
await self.delete_user(username)
await ctx.set_aspect('delete-username', text='')
await ctx.set_aspect('confirm-delete-user-modal', active=False)
await ctx.set_aspect(
'users-listbox', delete_identity=f'user-row${username}')
[docs] async def on_create_role(self, ctx: CallContext):
role_name = ctx.states['new-role-name']['value']
role_description = ctx.states['new-role-description']['value']
role_data = ctx.states['roles-data']['data']
try:
await self.create_role(role_name, role_description)
await ctx.set_aspect(
'role-listbox',
append=self.get_role_row(
AdminRole(role_name, role_description)
)
)
await ctx.set_aspect('new-role-name', value='')
await ctx.set_aspect('new-role-description', value='')
await ctx.set_aspect('roles-data', data=role_data + [role_name])
except Exception as err: # pylint: disable=broad-except
self.app.logger.exception(err)
await ctx.set_aspect(
'toast-listbox',
append=self._extra.Toast(
self._core.Text(traceback.format_exc()),
style={'border': 'red 3px solid'},
position='top-right',
delay=12000
)
)
[docs] async def on_role_delete(self, ctx: CallContext):
role_name = _get_trigger_meta(ctx.trigger.identity)
roles = ctx.states['roles-data']['data']
await self.delete_role(role_name)
await ctx.set_aspect(
'role-listbox', delete_identity=f'role-row-{role_name}')
await ctx.set_aspect(
'roles-data', data=[x for x in roles if x != role_name])
async def _update_users(self, offset, roles, filters, ctx: CallContext):
users = await self.get_users(offset, self.users_per_page, filters)
await ctx.set_aspect(
'users-listbox',
items=[self.get_user_row(user, roles) for user in users]
)
[docs] async def on_change_users_page(self, ctx: CallContext):
offset = ctx.trigger.value
roles = ctx.states['roles-data']['data']
filters = ctx.states['user-filters-data']['data']
await self._update_users(offset, roles, filters, ctx)
[docs] async def on_apply_user_filters(self, ctx: CallContext):
filters = ctx.trigger.value
roles = ctx.states['roles-data']['data']
await self._update_users(0, roles, filters, ctx)
user_count = await self.get_user_count(filters)
await ctx.set_aspect(
'users-pager', total_items=user_count, current_page=1)
[docs] async def on_description_change(self, ctx: CallContext):
role_name = _get_trigger_meta(ctx.trigger.identity)
await self.update_role_description(role_name, ctx.trigger.value)
# To implement
[docs] async def get_users(self, offset: int, limit: int, filters: dict = None):
raise NotImplementedError
[docs] async def get_user_count(self, filters: dict = None):
raise NotImplementedError
[docs] async def get_roles(self):
raise NotImplementedError
[docs] async def toggle_active_user(self, username: str, active: bool):
raise NotImplementedError
[docs] async def change_user_roles(self, username: str, roles: typing.List[str]):
raise NotImplementedError
[docs] async def delete_user(self, username: str):
raise NotImplementedError
[docs] async def create_role(self, role: str, description: str):
raise NotImplementedError
[docs] async def delete_role(self, role: str):
raise NotImplementedError
[docs] async def update_role_description(self, role: str, description: str):
raise NotImplementedError