backoffice: allow roles to manage their own custom views (#77194)
gitea/wcs/pipeline/head This commit looks good Details

This commit is contained in:
Frédéric Péters 2023-05-14 10:09:59 +02:00
parent 156e53a305
commit 7dc831b51a
8 changed files with 348 additions and 40 deletions

View File

@ -130,6 +130,8 @@ def test_export_import_dependencies(pub):
role2.store()
role3 = pub.role_class(name='Third role')
role3.store()
role4 = pub.role_class(name='Fourth role')
role4.store()
wscall = NamedWsCall(name='Test')
wscall.store()
@ -179,6 +181,15 @@ def test_export_import_dependencies(pub):
resp = get_app(pub).get(sign_uri(resp.json['data'][0]['urls']['dependencies']))
assert not resp.json['data']
custom_view = pub.custom_view_class()
custom_view.title = 'shared formdef custom view'
custom_view.formdef = formdef
custom_view.columns = {'list': [{'id': '1'}]}
custom_view.filters = {}
custom_view.visibility = 'role'
custom_view.role_id = role4.id
custom_view.store()
formdef.roles = ['logged-users']
formdef.backoffice_submission_roles = [role2.id]
formdef.workflow_roles = {'_receiver': role3.id}
@ -323,6 +334,7 @@ def test_export_import_dependencies(pub):
('test-quater', 'forms'),
('second-role', 'roles'),
('third-role', 'roles'),
('fourth-role', 'roles'),
}
for dependency in resp.json['data']:
get_app(pub).get(sign_uri(dependency['urls']['export']))

View File

@ -11,6 +11,7 @@ from wcs.categories import CardDefCategory
from wcs.formdef import FormDef
from wcs.qommon.http_request import HTTPRequest
from wcs.qommon.ident.password_accounts import PasswordAccount
from wcs.workflows import Workflow
from ..utilities import clean_temporary_pub, create_temporary_pub, get_app, login
from .test_all import create_superuser, create_user
@ -609,7 +610,10 @@ def test_backoffice_custom_view_visibility(pub):
assert resp.text.count('<span>User Label</span>') == 0
resp.forms['save-custom-view']['title'] = 'custom test view'
assert 'visibility' not in resp.forms['save-custom-view'].fields
assert resp.forms['save-custom-view'].fields['visibility'][0].options == [
('owner', True, None),
('role', False, None),
]
resp = resp.forms['save-custom-view'].submit()
assert resp.location.endswith('/user-custom-test-view/')
resp = resp.follow()
@ -655,6 +659,116 @@ def test_backoffice_custom_view_visibility(pub):
}
def test_backoffice_custom_view_role_visibility(pub):
pub.user_class.wipe()
pub.role_class.wipe()
role1 = pub.role_class(name='foo')
role1.allows_backoffice_access = True
role1.store()
role2 = pub.role_class(name='bar')
role2.allows_backoffice_access = True
role2.store()
role3 = pub.role_class(name='baz')
role3.allows_backoffice_access = True
role3.store()
FormDef.wipe()
pub.custom_view_class.wipe()
Workflow.wipe()
workflow = Workflow(name='wf')
workflow.roles = {'_foobar': 'Foobar', '_baz': 'Baz'}
formdef = FormDef()
formdef.name = 'form title'
formdef.fields = []
formdef.workflow_roles = {'_foobar': role1.id, '_baz': role3.id}
formdef.store()
agent = pub.user_class(name='agent')
agent.roles = [role1.id, role2.id]
agent.store()
account = PasswordAccount(id='agent')
account.set_password('agent')
account.user_id = agent.id
account.store()
app = login(get_app(pub), username='agent', password='agent')
resp = app.get('/backoffice/management/form-title/')
# columns
resp.forms['listing-settings']['user-label'].checked = False
resp = resp.forms['listing-settings'].submit()
assert resp.text.count('<span>User Label</span>') == 0
resp.forms['save-custom-view']['title'] = 'custom test view'
resp.forms['save-custom-view']['visibility'] = 'role'
# only user roles listed in formdef functions are displayed
# (role2 is not there as it's not in the formdef functions and
# role3 is not there as the user is not a member)
assert resp.forms['save-custom-view']['role'].options == [('None', True, '---'), (role1.id, False, 'foo')]
resp.forms['save-custom-view']['role'] = role1.id
resp = resp.forms['save-custom-view'].submit()
assert resp.location.endswith('/custom-test-view/')
resp = resp.follow()
assert resp.text.count('<span>User Label</span>') == 0
# second agent
agent2 = pub.user_class(name='agent2')
agent2.roles = [role3.id]
agent2.store()
account = PasswordAccount(id='agent2')
account.set_password('agent2')
account.user_id = agent2.id
account.store()
app = login(get_app(pub), username='agent2', password='agent2')
resp = app.get('/backoffice/management/form-title/')
assert 'custom test view' not in resp
resp = app.get('/backoffice/management/form-title/custom-test-view/', status=404)
agent2.roles = [role1.id, role3.id]
agent2.store()
resp = app.get('/backoffice/management/form-title/')
assert 'custom test view' in resp.text
resp = app.get('/backoffice/management/form-title/custom-test-view/', status=200)
assert resp.forms['listing-settings']['user-label'].checked is False
# allow updating view
resp.forms['listing-settings']['user-label'].checked = True
resp.forms['listing-settings']['last_update_time'].checked = False
resp = resp.forms['listing-settings'].submit()
resp.forms['save-custom-view']['title'] = 'custom test view'
resp.forms['save-custom-view']['visibility'] = 'role'
resp.forms['save-custom-view']['role'] = role1.id
resp.forms['save-custom-view']['update'].checked = True
resp = resp.forms['save-custom-view'].submit()
assert {(x.slug, x.visibility) for x in get_publisher().custom_view_class.select()} == {
('custom-test-view', 'role')
}
resp = app.get('/backoffice/management/form-title/')
resp = resp.click('custom test view')
assert resp.forms['listing-settings']['user-label'].checked is True
assert resp.forms['listing-settings']['last_update_time'].checked is False
# do not allow duplicated slugs
resp.forms['listing-settings']['user-label'].checked = False
resp.forms['listing-settings']['last_update_time'].checked = False
resp = resp.forms['listing-settings'].submit()
resp.forms['save-custom-view']['title'] = 'custom test view'
resp.forms['save-custom-view']['visibility'] = 'role'
resp.forms['save-custom-view']['role'] = role1.id
resp.forms['save-custom-view']['update'].checked = False
resp = resp.forms['save-custom-view'].submit()
assert {(x.slug, x.visibility) for x in get_publisher().custom_view_class.select()} == {
('custom-test-view', 'role'),
('custom-test-view-2', 'role'),
}
def test_backoffice_carddef_custom_view_visibility(pub):
user = create_superuser(pub)

View File

@ -786,6 +786,9 @@ def test_custom_views(pub):
]
formdef.store()
role = pub.role_class(name='Test')
role.store()
# define also custom views
pub.custom_view_class.wipe()
@ -804,10 +807,20 @@ def test_custom_views(pub):
custom_view.columns = {'list': [{'id': 'id'}]}
custom_view.filters = {}
custom_view.visibility = 'owner'
custom_view.usier_id = 42
custom_view.user_id = '42'
custom_view.order_by = 'id'
custom_view.store()
custom_view = pub.custom_view_class()
custom_view.title = 'shared form view on role'
custom_view.formdef = formdef
custom_view.columns = {'list': [{'id': 'id'}]}
custom_view.filters = {}
custom_view.order_by = 'id'
custom_view.visibility = 'role'
custom_view.role_id = role.id
custom_view.store()
formdef_xml = formdef.export_to_xml()
assert formdef_xml.tag == 'formdef'
formdef.data_class().wipe()
@ -818,7 +831,8 @@ def test_custom_views(pub):
assert formdef2._custom_views
custom_views = formdef2._custom_views
assert len(custom_views) == 1
custom_views.sort(key=lambda x: x.slug)
assert len(custom_views) == 2
assert custom_views[0].title == 'shared form view'
assert custom_views[0].slug == 'shared-form-view'
assert custom_views[0].columns == {'list': [{'id': 'id'}, {'id': 'time'}, {'id': 'status'}]}
@ -833,9 +847,19 @@ def test_custom_views(pub):
assert custom_views[0].formdef_id is None
assert custom_views[0].formdef_type is None
assert custom_views[1].title == 'shared form view on role'
assert custom_views[1].slug == 'shared-form-view-on-role'
assert custom_views[1].columns == {'list': [{'id': 'id'}]}
assert custom_views[1].filters == {}
assert custom_views[1].visibility == 'role'
assert custom_views[1].role_id == role.id
assert custom_views[1].order_by == 'id'
assert custom_views[1].formdef_id is None
assert custom_views[1].formdef_type is None
formdef2.store()
custom_views = pub.custom_view_class.select()
assert len(custom_views) == 1
custom_views = pub.custom_view_class.select(order_by='slug')
assert len(custom_views) == 2
assert custom_views[0].title == 'shared form view'
assert custom_views[0].slug == 'shared-form-view'
assert custom_views[0].columns == {'list': [{'id': 'id'}, {'id': 'time'}, {'id': 'status'}]}
@ -850,6 +874,67 @@ def test_custom_views(pub):
assert custom_views[0].formdef_id == formdef2.id
assert custom_views[0].formdef_type == 'formdef'
assert custom_views[1].title == 'shared form view on role'
assert custom_views[1].slug == 'shared-form-view-on-role'
assert custom_views[1].columns == {'list': [{'id': 'id'}]}
assert custom_views[1].filters == {}
assert custom_views[1].visibility == 'role'
assert custom_views[1].role_id == role.id
assert custom_views[1].order_by == 'id'
assert custom_views[1].formdef_id == formdef2.id
assert custom_views[1].formdef_type == 'formdef'
def test_custom_views_include_id(pub):
formdef = FormDef()
formdef.name = 'foo'
formdef.fields = [
fields.StringField(id='1', label='Foo', varname='foo'),
fields.StringField(id='2', label='Bar', varname='bar'),
]
formdef.store()
role1 = pub.role_class(name='Test1')
role1.store()
role2 = pub.role_class(name='Test2')
role2.store()
pub.custom_view_class.wipe()
custom_view = pub.custom_view_class()
custom_view.title = 'shared form view on role'
custom_view.formdef = formdef
custom_view.columns = {'list': [{'id': 'id'}]}
custom_view.filters = {}
custom_view.order_by = 'id'
custom_view.visibility = 'role'
custom_view.role_id = role1.id
custom_view.store()
formdef_xml = formdef.export_to_xml(include_id=True)
formdef.data_class().wipe()
pub.custom_view_class.wipe()
# recreate role, it will get a different id
role1.remove_self()
role3 = pub.role_class(name='Test1')
role3.store()
role2.remove_self()
formdef2 = FormDef.import_from_xml(io.BytesIO(ET.tostring(formdef_xml)), include_id=False)
formdef2.store()
custom_views = formdef2._custom_views
assert custom_views[0].title == 'shared form view on role'
assert custom_views[0].slug == 'shared-form-view-on-role'
assert custom_views[0].columns == {'list': [{'id': 'id'}]}
assert custom_views[0].filters == {}
assert custom_views[0].visibility == 'role'
assert custom_views[0].role_id == role3.id # will match on slug
assert custom_views[0].order_by == 'id'
assert custom_views[0].formdef_id == formdef2.id
assert custom_views[0].formdef_type == 'formdef'
def test_import_formdef_multiple_errors(pub):
BlockDef.wipe()

View File

@ -43,7 +43,7 @@ from wcs.formdata import FormData, NoContentSnapshotAt
from wcs.formdef import FormDef, UpdateDigestAfterJob
from wcs.forms.backoffice import FormDefUI
from wcs.forms.common import FormdefDirectoryBase, FormStatusPage
from wcs.roles import logged_users_role
from wcs.roles import get_user_roles, logged_users_role
from wcs.sql_criterias import (
And,
Contains,
@ -1508,12 +1508,15 @@ class FormPage(FormdefDirectoryBase):
required=True,
value=self.view.title if self.view else None,
)
can_update = False
if self.formdef.has_admin_access(get_request().user):
# admins can create views accessible to everyone
# admins can create views accessible to roles or any users
options = [
('owner', _('to me only'), 'owner'),
('role', _('to role'), 'role'),
('any', _('to any users'), 'any'),
]
can_update = True
if isinstance(self.formdef, CardDef) and self.formdef.default_digest_template:
options.append(('datasource', _('as data source'), 'datasource'))
@ -1527,6 +1530,20 @@ class FormPage(FormdefDirectoryBase):
attrs={'data-dynamic-display-parent': 'true'},
extra_css_class='widget-inline-radio',
)
role_options = [(None, '---', None)]
role_options.extend(get_user_roles())
form.add(
SingleSelectWidget,
'role',
title=_('Role'),
value=self.view.role_id if self.view else None,
options=role_options,
attrs={
'data-dynamic-display-child-of': 'visibility',
'data-dynamic-display-value-in': 'role',
},
)
form.add(
CheckboxWidget,
'is_default',
@ -1534,7 +1551,7 @@ class FormPage(FormdefDirectoryBase):
value=self.view.is_default if self.view else False,
attrs={
'data-dynamic-display-child-of': 'visibility',
'data-dynamic-display-value-in': 'owner|any',
'data-dynamic-display-value-in': 'owner|role|any',
},
)
if isinstance(self.formdef, CardDef):
@ -1553,9 +1570,44 @@ class FormPage(FormdefDirectoryBase):
'data-dynamic-display-value-in': 'datasource|any',
},
)
if self.view and (
self.view.user_id == get_request().user.id or self.formdef.has_admin_access(get_request().user)
):
else:
user_roles = get_request().user.get_roles()
static_function_role_ids = [x for x in self.formdef.workflow_roles.values() if x in user_roles]
static_function_roles = get_publisher().role_class.select(
[Contains('id', static_function_role_ids)]
)
if static_function_roles:
# users can create custom views for their roles
options = [
('owner', _('to me only'), 'owner'),
('role', _('to role'), 'role'),
]
can_update = True
form.add(
RadiobuttonsWidget,
'visibility',
title=_('Visibility'),
value=self.view.visibility if self.view else 'owner',
options=options,
attrs={'data-dynamic-display-parent': 'true'},
extra_css_class='widget-inline-radio',
)
role_options = [(None, '---', None)]
role_options.extend([(x.id, x.name, x.id) for x in static_function_roles])
form.add(
SingleSelectWidget,
'role',
title=_('Role'),
value=self.view.role_id if self.view else None,
options=role_options,
attrs={
'data-dynamic-display-child-of': 'visibility',
'data-dynamic-display-value-in': 'role',
},
)
if self.view and (self.view.user_id == get_request().user.id or can_update):
form.add(CheckboxWidget, 'update', title=_('Update existing view settings'), value=True)
form.add_submit('submit', _('Save View'))
form.add_submit('cancel', _('Cancel'))
@ -1585,6 +1637,12 @@ class FormPage(FormdefDirectoryBase):
custom_view.visibility = form.get_widget('visibility').parse()
if custom_view.visibility == 'datasource':
custom_view.is_default = False
if custom_view.visibility == 'role':
custom_view.role_id = form.get_widget('role').parse()
if not custom_view.role_id:
custom_view.visibility = 'owner'
else:
custom_view.role_id = None
custom_view.store()
if form.get_widget('digest_template') and custom_view.visibility != 'owner':
@ -1617,7 +1675,11 @@ class FormPage(FormdefDirectoryBase):
for view in self.get_custom_views():
if view.id == custom_view.id:
continue
if custom_view.visibility == view.visibility and view.is_default:
if (
custom_view.visibility == view.visibility
and view.is_default
and view.role_id == custom_view.role_id
):
view.is_default = False
view.store()

View File

@ -36,6 +36,7 @@ class CustomView(StorableObject):
title = None
slug = None
user_id = None
role_id = None
visibility = 'owner'
formdef_type = None
formdef_id = None
@ -78,7 +79,7 @@ class CustomView(StorableObject):
formdef.store()
super().remove_self()
def match(self, user, formdef):
def match(self, user, formdef, for_export=False):
if self.formdef_type != formdef.xml_root_node:
return False
if self.formdef_id != str(formdef.id):
@ -87,6 +88,12 @@ class CustomView(StorableObject):
user is None or not hasattr(user, 'id') or self.user_id != str(user.id)
):
return False
if (
self.visibility == 'role'
and not for_export
and (user is None or self.role_id not in user.get_roles())
):
return False
return True
def set_from_qs(self, qs):
@ -210,7 +217,7 @@ class CustomView(StorableObject):
return criterias
def export_to_xml(self, charset=None):
def export_to_xml(self, charset=None, include_id=False):
root = ET.Element(self.xml_root_node)
fields = [
'title',
@ -239,9 +246,21 @@ class CustomView(StorableObject):
el.text = force_str(val, charset, errors='replace')
else:
el.text = str(val)
if self.visibility == 'role' and self.role_id:
from wcs.workflows import get_role_name_and_slug
role_name, role_slug = get_role_name_and_slug(self.role_id)
sub = ET.SubElement(root, 'role')
if role_slug:
sub.attrib['slug'] = role_slug
if include_id:
sub.attrib['role_id'] = str(self.role_id)
sub.text = role_name
return root
def init_with_xml(self, elem, charset):
def init_with_xml(self, elem, charset, include_id=False):
fields = [
'title',
'slug',
@ -266,3 +285,9 @@ class CustomView(StorableObject):
setattr(self, attribute, {'list': v})
else:
setattr(self, attribute, xml_node_text(el))
self.role_id = get_publisher().role_class.get_role_by_node(elem.find('role'), include_id=include_id)
def get_dependencies(self):
if self.visibility == 'role' and self.role_id:
yield get_publisher().role_class.get(self.role_id, ignore_errors=True)

View File

@ -593,6 +593,9 @@ class FormDef(StorableObject):
yield role_class.get(role_id, ignore_errors=True)
for role_id in (self.workflow_roles or {}).values():
yield role_class.get(role_id, ignore_errors=True)
for view in get_publisher().custom_view_class.select():
if view.match(user=None, formdef=self, for_export=True):
yield from view.get_dependencies()
@property
def keywords_list(self):
@ -1273,10 +1276,10 @@ class FormDef(StorableObject):
else:
custom_views = []
for view in get_publisher().custom_view_class.select():
if view.match(user=None, formdef=self):
if view.match(user=None, formdef=self, for_export=True):
custom_views.append(view)
for view in custom_views:
custom_views_element.append(view.export_to_xml(charset=charset))
custom_views_element.append(view.export_to_xml(charset=charset, include_id=include_id))
geolocations = ET.SubElement(root, 'geolocations')
for geoloc_key, geoloc_label in (self.geolocations or {}).items():
@ -1416,7 +1419,7 @@ class FormDef(StorableObject):
formdef._custom_views = []
for view in tree.findall('custom_views/%s' % get_publisher().custom_view_class.xml_root_node):
view_o = get_publisher().custom_view_class()
view_o.init_with_xml(view, charset)
view_o.init_with_xml(view, charset, include_id=include_id)
formdef._custom_views.append(view_o)
cls.category_class.object_category_xml_import(formdef, tree, include_id=include_id)
@ -1440,24 +1443,6 @@ class FormDef(StorableObject):
formdef.workflow_id = w.id
break
def get_role_by_node(role_node):
role_id = None
value = xml_node_text(role_node)
if value.startswith('_') or value == 'logged-users':
return value
if include_id:
role_id = role_node.attrib.get('role_id')
if role_id and get_publisher().role_class.get(role_id, ignore_errors=True):
return role_id
role_slug = role_node.attrib.get('slug')
role = get_publisher().role_class.resolve(uuid=None, slug=role_slug, name=value)
if role:
return role.id
return None
roles_elements = [
('roles', 'user-roles'),
('backoffice_submission_roles', 'backoffice-submission-roles'),
@ -1469,7 +1454,7 @@ class FormDef(StorableObject):
roles = []
setattr(formdef, attr_name, roles)
for child in roles_node:
role_id = get_role_by_node(child)
role_id = get_publisher().role_class.get_role_by_node(child, include_id=include_id)
if role_id:
roles.append(role_id)
@ -1478,7 +1463,7 @@ class FormDef(StorableObject):
formdef.workflow_roles = {}
for child in roles_node:
role_key = child.attrib['role_key']
role_id = get_role_by_node(child)
role_id = get_publisher().role_class.get_role_by_node(child, include_id=include_id)
formdef.workflow_roles[role_key] = role_id
if tree.find('geolocations') is not None:

View File

@ -186,6 +186,26 @@ class Role(StorableObject):
return htmltext('<a href="%(url)s">%(name)s</a>') % {'url': url, 'name': self.name}
@classmethod
def get_role_by_node(cls, role_node, include_id=False):
if role_node is None:
return None
value = misc.xml_node_text(role_node)
if value.startswith('_') or value == 'logged-users':
return value
if include_id:
role_id = role_node.attrib.get('role_id')
if role_id and cls.get(role_id, ignore_errors=True):
return role_id
role_slug = role_node.attrib.get('slug')
role = cls.resolve(uuid=None, slug=role_slug, name=value)
if role:
return role.id
return None
def logged_users_role():
volatile_role = Role.volatile()

View File

@ -1012,6 +1012,8 @@ def do_custom_views_table():
# migrations
if 'is_default' not in existing_fields:
cur.execute('''ALTER TABLE %s ADD COLUMN is_default boolean DEFAULT FALSE''' % table_name)
if 'role_id' not in existing_fields:
cur.execute('''ALTER TABLE %s ADD COLUMN role_id VARCHAR''' % table_name)
# delete obsolete fields
for field in existing_fields - needed_fields:
@ -3423,6 +3425,7 @@ class CustomView(SqlMixin, wcs.custom_views.CustomView):
('title', 'varchar'),
('slug', 'varchar'),
('user_id', 'varchar'),
('role_id', 'varchar'),
('visibility', 'varchar'),
('formdef_type', 'varchar'),
('formdef_id', 'varchar'),
@ -3441,6 +3444,7 @@ class CustomView(SqlMixin, wcs.custom_views.CustomView):
'title': self.title,
'slug': self.slug,
'user_id': self.user_id,
'role_id': self.role_id,
'visibility': self.visibility,
'formdef_type': self.formdef_type,
'formdef_id': self.formdef_id,
@ -4969,7 +4973,7 @@ def get_period_total(
# latest migration, number + description (description is not used
# programmaticaly but will make sure git conflicts if two migrations are
# separately added with the same number)
SQL_LEVEL = (89, 'rerun creation of test results table')
SQL_LEVEL = (90, 'add role_id to custom views')
def migrate_global_views(conn, cur):
@ -5129,10 +5133,11 @@ def migrate():
if sql_level < 64:
# 64: add transient data table
do_transient_data_table()
if sql_level < 66:
if sql_level < 90:
# 37: create custom_views table
# 44: add is_default column to custom_views table
# 66: index the formdef_id column
# 90: add role_id to custom views
do_custom_views_table()
if sql_level < 67:
# 57: store tokens in SQL