tailbone/tailbone/views/users.py
Lance Edgar c002d3d182 Add basic support for managing, and accepting API tokens
also various other changes in pursuit of that.  so far tokens are only
accepted by web API and not traditional web app
2023-05-15 08:10:42 -05:00

745 lines
27 KiB
Python

# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2023 Lance Edgar
#
# This file is part of Rattail.
#
# Rattail is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# Rattail. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
User Views
"""
import sqlalchemy as sa
from sqlalchemy import orm
from rattail.db.model import User, UserEvent
from rattail.db.auth import (administrator_role, guest_role,
authenticated_role, set_user_password)
import colander
from deform import widget as dfwidget
from webhelpers2.html import HTML, tags
from tailbone import forms
from tailbone.views import MasterView, View
from tailbone.views.principal import PrincipalMasterView, PermissionsRenderer
from tailbone.util import raw_datetime
class UserView(PrincipalMasterView):
"""
Master view for the User model.
"""
model_class = User
has_rows = True
model_row_class = UserEvent
has_versions = True
touchable = True
mergeable = True
labels = {
'api_tokens': "API Tokens",
}
grid_columns = [
'username',
'person',
'active',
'local_only',
]
form_fields = [
'username',
'person',
'first_name_',
'last_name_',
'display_name_',
'active',
'active_sticky',
'set_password',
'prevent_password_change',
'api_tokens',
'roles',
'permissions',
]
row_grid_columns = [
'type_code',
'occurred',
]
def __init__(self, request):
super(UserView, self).__init__(request)
app = self.get_rattail_app()
# always get a reference to the auth/merge handler
self.auth_handler = app.get_auth_handler()
self.merge_handler = self.auth_handler
def query(self, session):
query = super(UserView, self).query(session)
model = self.model
# bring in the related Person(s)
query = query.outerjoin(model.Person)\
.options(orm.joinedload(model.User.person))
return query
def configure_grid(self, g):
super(UserView, self).configure_grid(g)
model = self.model
del g.filters['salt']
g.filters['username'].default_active = True
g.filters['username'].default_verb = 'contains'
g.filters['active'].default_active = True
g.filters['active'].default_verb = 'is_true'
g.filters['person'] = g.make_filter('person', model.Person.display_name,
default_active=True, default_verb='contains')
# password
g.set_filter('password', model.User.password,
verbs=['is_null', 'is_not_null'])
g.set_sorter('person', model.Person.display_name)
g.set_sorter('first_name', model.Person.first_name)
g.set_sorter('last_name', model.Person.last_name)
g.set_sorter('display_name', model.Person.display_name)
g.set_sort_defaults('username')
g.set_label('person', "Person's Name")
g.set_link('username')
g.set_link('person')
g.set_link('first_name')
g.set_link('last_name')
g.set_link('display_name')
def grid_extra_class(self, user, i):
if not user.active:
return 'warning'
def editable_instance(self, user):
"""
If the given user is "protected" then we only allow edit if current
user is "root". But if the given user is not protected, this simply
returns ``True``.
"""
if self.request.is_root:
return True
return not self.user_is_protected(user)
def deletable_instance(self, user):
"""
If the given user is "protected" then we only allow delete if current
user is "root". But if the given user is not protected, this simply
returns ``True``.
"""
if self.request.is_root:
return True
return not self.user_is_protected(user)
def unique_username(self, node, value):
model = self.model
query = self.Session.query(model.User)\
.filter(model.User.username == value)
if self.editing:
user = self.get_instance()
query = query.filter(model.User.uuid != user.uuid)
if query.count():
raise colander.Invalid(node, "Username must be unique")
def valid_person(self, node, value):
"""
Make sure ``value`` corresponds to an existing
``Person.uuid``.
"""
if value:
model = self.model
person = self.Session.get(model.Person, value)
if not person:
raise colander.Invalid(node, "Person not found (you must *select* a record)")
def configure_form(self, f):
super(UserView, self).configure_form(f)
model = self.model
user = f.model_instance
# username
f.set_validator('username', self.unique_username)
# person
f.set_renderer('person', self.render_person)
if self.creating or self.editing:
if 'person' in f.fields:
f.replace('person', 'person_uuid')
f.set_node('person_uuid', colander.String(), missing=colander.null)
person_display = ""
if self.request.method == 'POST':
if self.request.POST.get('person_uuid'):
person = self.Session.get(model.Person, self.request.POST['person_uuid'])
if person:
person_display = str(person)
elif self.editing:
person_display = str(user.person or '')
people_url = self.request.route_url('people.autocomplete')
f.set_widget('person_uuid', forms.widgets.JQueryAutocompleteWidget(
field_display=person_display, service_url=people_url))
f.set_validator('person_uuid', self.valid_person)
f.set_label('person_uuid', "Person")
# person name(s)
if self.editing:
# must explicitly set default, for "custom" field names
f.set_default('first_name_', user.first_name or "")
f.set_default('last_name_', user.last_name or "")
f.set_default('display_name_', user.display_name or "")
elif not self.creating:
# must provide custom renderer as well
f.set_renderer('first_name_', self.render_person_name)
f.set_renderer('last_name_', self.render_person_name)
f.set_renderer('display_name_', self.render_person_name)
# set_password
if self.editing and user.prevent_password_change and not self.request.is_root:
f.remove('set_password')
else:
f.set_widget('set_password', dfwidget.CheckedPasswordWidget())
# if self.creating:
# f.set_required('password')
# api_tokens
if self.creating or self.editing:
f.remove('api_tokens')
elif self.has_perm('manage_api_tokens'):
f.set_renderer('api_tokens', self.render_api_tokens)
f.set_vuejs_component_kwargs(**{':apiTokens': 'apiTokens',
'@api-new-token': 'apiNewToken',
'@api-token-delete': 'apiTokenDelete'})
else:
f.remove('api_tokens')
# roles
f.set_renderer('roles', self.render_roles)
if self.creating or self.editing:
if not self.has_perm('edit_roles'):
f.remove_field('roles')
else:
roles = self.get_possible_roles().all()
role_values = [(s.uuid, str(s)) for s in roles]
f.set_node('roles', colander.Set())
size = len(roles)
if size < 3:
size = 3
elif size > 20:
size = 20
f.set_widget('roles', dfwidget.SelectWidget(multiple=True,
size=size,
values=role_values))
if self.editing:
f.set_default('roles', [r.uuid for r in user.roles])
elif not self.has_perm('view_roles'):
f.remove_field('roles')
f.set_label('display_name', "Full Name")
# # hm this should work according to MDN but doesn't seem to...
# # https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion
# fs.username.attrs(autocomplete='new-password')
# fs.password.attrs(autocomplete='new-password')
# fs.confirm_password.attrs(autocomplete='new-password')
if self.viewing:
permissions = self.request.registry.settings.get('tailbone_permissions', {})
f.set_renderer('permissions', PermissionsRenderer(request=self.request,
permissions=permissions,
include_guest=True,
include_authenticated=True))
else:
f.remove('permissions')
if self.viewing or self.deleting:
f.remove('set_password')
def render_api_tokens(self, user, field):
route_prefix = self.get_route_prefix()
permission_prefix = self.get_permission_prefix()
factory = self.get_grid_factory()
g = factory(
key='{}.api_tokens'.format(route_prefix),
data=[],
columns=['description', 'created'],
main_actions=[
self.make_action('delete', icon='trash',
click_handler="$emit('api-token-delete', props.row)")])
button = self.make_buefy_button("New", is_primary=True,
icon_left='plus',
**{'@click': "$emit('api-new-token')"})
table = HTML.literal(
g.render_buefy_table_element(data_prop='apiTokens'))
return HTML.tag('div', c=[button, table])
def add_api_token(self):
user = self.get_instance()
data = self.request.json_body
token = self.auth_handler.add_api_token(user, data['description'])
self.Session.flush()
return {'ok': True,
'raw_token': token.token_string,
'tokens': self.get_api_tokens(user)}
def delete_api_token(self):
model = self.model
user = self.get_instance()
data = self.request.json_body
token = self.Session.get(model.UserAPIToken, data['uuid'])
if not token:
return {'error': "API token not found"}
if token.user is not user:
return {'error': "API token not found"}
self.auth_handler.delete_api_token(token)
self.Session.flush()
return {'ok': True,
'tokens': self.get_api_tokens(user)}
def template_kwargs_view(self, **kwargs):
kwargs = super(UserView, self).template_kwargs_view(**kwargs)
user = kwargs['instance']
kwargs['api_tokens_data'] = self.get_api_tokens(user)
return kwargs
def get_api_tokens(self, user):
tokens = []
for token in reversed(user.api_tokens):
tokens.append({
'uuid': token.uuid,
'description': token.description,
'created': raw_datetime(self.rattail_config, token.created),
})
return tokens
def get_possible_roles(self):
model = self.model
# some roles should never have users "belong" to them
excluded = [
guest_role(self.Session()).uuid,
authenticated_role(self.Session()).uuid,
]
# only allow "root" user to change true admin role membership
if not self.request.is_root:
excluded.append(administrator_role(self.Session()).uuid)
# basic list, minus exclusions so far
roles = self.Session.query(model.Role)\
.filter(~model.Role.uuid.in_(excluded))
# only allow "admin" user to change admin-ish role memberships
if not self.request.is_admin:
roles = roles.filter(sa.or_(
model.Role.adminish == False,
model.Role.adminish == None))
return roles.order_by(model.Role.name)
def objectify(self, form, data=None):
model = self.model
# create/update user as per normal
if data is None:
data = form.validated
user = super(UserView, self).objectify(form, data)
# create/update person as needed
names = {}
if 'first_name_' in form and data['first_name_']:
names['first'] = data['first_name_']
if 'last_name_' in form and data['last_name_']:
names['last'] = data['last_name_']
if 'display_name_' in form and data['display_name_']:
names['full'] = data['display_name_']
# we will not have a person reference yet, when creating new user. if
# that is the case, go ahead and load it, if specified.
if self.creating and user.person_uuid:
self.Session.add(user)
self.Session.flush()
# note, do *not* create new person unless name(s) provided
if not user.person and any([n for n in names.values()]):
user.person = model.Person()
if user.person:
app = self.get_rattail_app()
handler = app.get_people_handler()
handler.update_names(user.person, **names)
# force "local only" flag unless global access granted
if self.secure_global_objects:
if not self.has_perm('view_global'):
user.person.local_only = True
# maybe set user password
if 'set_password' in form and data['set_password']:
set_user_password(user, data['set_password'])
# update roles for user
self.update_roles(user, data)
return user
def update_roles(self, user, data):
if not self.has_perm('edit_roles'):
return
if 'roles' not in data:
return
model = self.model
old_roles = set([r.uuid for r in user.roles])
new_roles = data['roles']
admin = administrator_role(self.Session())
# add any new roles for the user, taking care not to add the admin role
# unless acting as root
for uuid in new_roles:
if uuid not in old_roles:
if self.request.is_root or uuid != admin.uuid:
user._roles.append(model.UserRole(role_uuid=uuid))
# also record a change to the role, for datasync.
# this is done "just in case" the role is to be
# synced to all nodes
if self.Session().rattail_record_changes:
self.Session.add(model.Change(class_name='Role',
instance_uuid=uuid,
deleted=False))
# remove any roles which were *not* specified, although must take care
# not to remove admin role, unless acting as root
for uuid in old_roles:
if uuid not in new_roles:
if self.request.is_root or uuid != admin.uuid:
role = self.Session.get(model.Role, uuid)
user.roles.remove(role)
# also record a change to the role, for datasync.
# this is done "just in case" the role is to be
# synced to all nodes
if self.Session().rattail_record_changes:
self.Session.add(model.Change(class_name='Role',
instance_uuid=uuid,
deleted=False))
def render_person(self, user, field):
person = user.person
if not person:
return ""
text = str(person)
url = self.request.route_url('people.view', uuid=person.uuid)
return tags.link_to(person, url)
def render_person_name(self, user, field):
if not field.endswith('_'):
return ""
name = getattr(user, field[:-1], None)
if not name:
return ""
return str(name)
def render_roles(self, user, field):
roles = sorted(user.roles, key=lambda r: r.name)
items = []
for role in roles:
text = role.name
url = self.request.route_url('roles.view', uuid=role.uuid)
items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
return HTML.tag('ul', c=items)
def get_row_data(self, user):
model = self.model
return self.Session.query(model.UserEvent)\
.filter(model.UserEvent.user == user)
def configure_row_grid(self, g):
super(UserView, self).configure_row_grid(g)
g.width = 'half'
g.filterable = False
g.set_sort_defaults('occurred', 'desc')
g.set_enum('type_code', self.enum.USER_EVENT)
g.set_label('type_code', "Event Type")
g.main_actions = []
def get_version_child_classes(self):
model = self.model
return [
(model.UserRole, 'user_uuid'),
]
def find_principals_with_permission(self, session, permission):
app = self.get_rattail_app()
auth = app.get_auth_handler()
model = self.model
# TODO: this should search Permission table instead, and work backward to User?
all_users = session.query(model.User)\
.filter(model.User.active == True)\
.order_by(model.User.username)\
.options(orm.joinedload(model.User._roles)\
.joinedload(model.UserRole.role)\
.joinedload(model.Role._permissions))
users = []
for user in all_users:
if auth.has_permission(session, user, permission):
users.append(user)
return users
def preferences(self, user=None):
"""
View to modify preferences for a particular user.
"""
current_user = True
if not user:
current_user = False
user = self.get_instance()
# TODO: this is of course largely copy/pasted from the
# MasterView.configure() method..should refactor?
if self.request.method == 'POST':
if self.request.POST.get('remove_settings'):
self.preferences_remove_settings(user)
self.request.session.flash("Settings have been removed.")
return self.redirect(self.request.current_route_url())
else:
data = self.request.POST
# then gather/save settings
settings = self.preferences_gather_settings(data, user)
self.preferences_remove_settings(user)
self.configure_save_settings(settings)
self.request.session.flash("Settings have been saved.")
return self.redirect(self.request.current_route_url())
context = self.preferences_get_context(user, current_user)
return self.render_to_response('preferences', context)
def my_preferences(self):
"""
View to modify preferences for the current user.
"""
user = self.request.user
if not user:
raise self.forbidden()
return self.preferences(user=user)
def preferences_get_context(self, user, current_user):
simple_settings = self.preferences_get_simple_settings(user)
context = self.configure_get_context(simple_settings=simple_settings,
input_file_templates=False)
instance_title = self.get_instance_title(user)
context.update({
'user': user,
'instance': user,
'instance_title': instance_title,
'instance_url': self.get_action_url('view', user),
'config_title': instance_title,
'config_preferences': True,
'current_user': current_user,
})
if current_user:
context.update({
'index_url': None,
'index_title': instance_title,
})
# theme style options
options = [{'value': None, 'label': "default"}]
styles = self.rattail_config.getlist('tailbone', 'themes.styles',
default=[])
for name in styles:
css = self.rattail_config.get('tailbone',
'themes.style.{}'.format(name))
if css:
options.append({'value': css, 'label': name})
context['buefy_css_options'] = options
return context
def preferences_get_simple_settings(self, user):
"""
This method is conceptually the same as for
:meth:`~tailbone.views.master.MasterView.configure_get_simple_settings()`.
See its docs for more info.
The only difference here is that we are given a user account,
so the settings involved should only pertain to that user.
"""
return [
# display
{'section': 'tailbone.{}'.format(user.uuid),
'option': 'buefy_css'},
]
def preferences_gather_settings(self, data, user):
simple_settings = self.preferences_get_simple_settings(user)
return self.configure_gather_settings(
data, simple_settings=simple_settings, input_file_templates=False)
def preferences_remove_settings(self, user):
simple_settings = self.preferences_get_simple_settings(user)
self.configure_remove_settings(simple_settings=simple_settings,
input_file_templates=False)
@classmethod
def defaults(cls, config):
cls._user_defaults(config)
cls._principal_defaults(config)
cls._defaults(config)
@classmethod
def _user_defaults(cls, config):
"""
Provide extra default configuration for the User master view.
"""
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
instance_url_prefix = cls.get_instance_url_prefix()
model_title = cls.get_model_title()
# view/edit roles
config.add_tailbone_permission(permission_prefix, '{}.view_roles'.format(permission_prefix),
"View the Roles to which a {} belongs".format(model_title))
config.add_tailbone_permission(permission_prefix, '{}.edit_roles'.format(permission_prefix),
"Edit the Roles to which a {} belongs".format(model_title))
# manage API tokens
config.add_tailbone_permission(permission_prefix,
'{}.manage_api_tokens'.format(permission_prefix),
"Manage API tokens for any {}".format(model_title))
config.add_route('{}.add_api_token'.format(route_prefix),
'{}/add-api-token'.format(instance_url_prefix),
request_method='POST')
config.add_view(cls, attr='add_api_token',
route_name='{}.add_api_token'.format(route_prefix),
permission='{}.manage_api_tokens'.format(permission_prefix),
renderer='json')
config.add_route('{}.delete_api_token'.format(route_prefix),
'{}/delete-api-token'.format(instance_url_prefix),
request_method='POST')
config.add_view(cls, attr='delete_api_token',
route_name='{}.delete_api_token'.format(route_prefix),
permission='{}.manage_api_tokens'.format(permission_prefix),
renderer='json')
# edit preferences for any user
config.add_tailbone_permission(permission_prefix,
'{}.preferences'.format(permission_prefix),
"Edit preferences for any {}".format(model_title))
config.add_route('{}.preferences'.format(route_prefix),
'{}/preferences'.format(instance_url_prefix))
config.add_view(cls, attr='preferences',
route_name='{}.preferences'.format(route_prefix),
permission='{}.preferences'.format(permission_prefix))
# edit "my" preferences (for current user)
config.add_route('my.preferences',
'/my/preferences')
config.add_view(cls, attr='my_preferences',
route_name='my.preferences')
# TODO: deprecate / remove this
UsersView = UserView
class UserEventView(MasterView):
"""
Master view for all user events
"""
model_class = UserEvent
url_prefix = '/user-events'
viewable = False
creatable = False
editable = False
deletable = False
grid_columns = [
'user',
'person',
'type_code',
'occurred',
]
def get_data(self, session=None):
query = super(UserEventView, self).get_data(session=session)
model = self.model
return query.join(model.User)
def configure_grid(self, g):
super(UserEventView, self).configure_grid(g)
model = self.model
g.set_joiner('person', lambda q: q.outerjoin(model.Person))
g.set_sorter('user', model.User.username)
g.set_sorter('person', model.Person.display_name)
g.filters['user'] = g.make_filter('user', model.User.username)
g.filters['person'] = g.make_filter('person', model.Person.display_name)
g.set_enum('type_code', self.enum.USER_EVENT)
g.set_type('occurred', 'datetime')
g.set_renderer('user', self.render_user)
g.set_renderer('person', self.render_person)
g.set_sort_defaults('occurred', 'desc')
g.set_label('user', "Username")
g.set_label('type_code', "Event Type")
def render_user(self, event, column):
return event.user.username
def render_person(self, event, column):
if event.user.person:
return event.user.person.display_name
# TODO: deprecate / remove this
UserEventsView = UserEventView
def defaults(config, **kwargs):
base = globals()
UserView = kwargs.get('UserView', base['UserView'])
UserView.defaults(config)
UserEventView = kwargs.get('UserEventView', base['UserEventView'])
UserEventView.defaults(config)
def includeme(config):
defaults(config)