general: add category-based management access (forms/cards/workflows) (#21991)
gitea-wip/wcs/pipeline/head Build started... Details

This commit is contained in:
Frédéric Péters 2021-08-03 15:07:17 +02:00
parent dada06c191
commit b4ee66cbcf
17 changed files with 564 additions and 84 deletions

View File

@ -1,6 +1,8 @@
import re
import xml.etree.ElementTree as ET
import pytest
from webtest import Upload
from wcs import fields
from wcs.admin.settings import UserFieldsFormDef
@ -560,3 +562,77 @@ def test_card_management_view(pub):
app = login(get_app(pub))
resp = app.get('/backoffice/cards/1/')
assert 'backoffice/data/foo/' in resp
def test_card_category_management_roles(pub, backoffice_user, backoffice_role):
app = login(get_app(pub), username='backoffice-user', password='backoffice-user')
app.get('/backoffice/cards/', status=403)
CardDefCategory.wipe()
cat = CardDefCategory(name='Foo')
cat.store()
CardDef.wipe()
carddef = CardDef()
carddef.name = 'card title'
carddef.category_id = cat.id
carddef.fields = []
carddef.store()
cat = CardDefCategory(name='Bar')
cat.management_roles = [backoffice_role]
cat.store()
resp = app.get('/backoffice/cards/')
assert 'Foo' not in resp.text # not a category managed by user
assert 'card title' not in resp.text # carddef in that category
assert 'Bar' not in resp.text # not yet any form in this category
resp = resp.click('New Card')
resp.forms[0]['name'] = 'card in category'
assert len(resp.forms[0]['category_id'].options) == 1 # single option
assert resp.forms[0]['category_id'].value == cat.id # the category managed by user
resp = resp.forms[0].submit().follow()
new_carddef = CardDef.get_by_urlname('card-in-category')
# check category select only let choose one
resp = resp.click(href='/category')
assert len(resp.forms[0]['category_id'].options) == 1 # single option
assert resp.forms[0]['category_id'].value == cat.id # the category managed by user
resp = app.get('/backoffice/cards/')
assert 'Bar' in resp.text # now there's a form in this category
assert 'card in category' in resp.text
# no access to subdirectories
assert 'href="categories/"' not in resp.text
app.get('/backoffice/cards/categories/', status=403)
# no import into other category
carddef_xml = ET.tostring(carddef.export_to_xml(include_id=True))
resp = resp.click(href='import')
resp.forms[0]['file'] = Upload('carddef.wcs', carddef_xml)
resp = resp.forms[0].submit()
assert 'Invalid File (unauthorized category)' in resp.text
# check access to inspect page
carddef.workflow_roles = {'_viewer': str(backoffice_role.id)}
carddef.store()
carddata = carddef.data_class()()
carddata.just_created()
carddata.store()
resp = app.get(carddata.get_backoffice_url())
assert 'inspect' not in resp.text
resp = app.get(carddata.get_backoffice_url() + 'inspect', status=403)
new_carddef.workflow_roles = {'_viewer': str(backoffice_role.id)}
new_carddef.store()
carddata = new_carddef.data_class()()
carddata.just_created()
carddata.store()
resp = app.get(carddata.get_backoffice_url())
assert 'inspect' in resp.text
resp = app.get(carddata.get_backoffice_url() + 'inspect')

View File

@ -2698,3 +2698,83 @@ def test_form_new_computed_field(pub):
assert FormDef.get(1).fields[0].key == 'computed'
assert FormDef.get(1).fields[0].label == 'foobar'
assert FormDef.get(1).fields[0].varname == 'foobar'
def test_form_category_management_roles(pub, backoffice_user, backoffice_role):
app = login(get_app(pub), username='backoffice-user', password='backoffice-user')
app.get('/backoffice/forms/', status=403)
Category.wipe()
cat = Category(name='Foo')
cat.store()
FormDef.wipe()
formdef = FormDef()
formdef.name = 'form title'
formdef.category_id = cat.id
formdef.fields = []
formdef.store()
cat = Category(name='Bar')
cat.management_roles = [backoffice_role]
cat.store()
resp = app.get('/backoffice/forms/')
assert 'Foo' not in resp.text # not a category managed by user
assert 'form title' not in resp.text # formdef in that category
assert 'Bar' not in resp.text # not yet any form in this category
app.get('/backoffice/forms/%s/' % formdef.id, status=403)
resp = resp.click('New Form')
resp.forms[0]['name'] = 'form in category'
assert len(resp.forms[0]['category_id'].options) == 1 # single option
assert resp.forms[0]['category_id'].value == cat.id # the category managed by user
resp = resp.forms[0].submit().follow()
new_formdef = FormDef.get_by_urlname('form-in-category')
# check category select only let choose one
resp = resp.click(href='/category')
assert len(resp.forms[0]['category_id'].options) == 1 # single option
assert resp.forms[0]['category_id'].value == cat.id # the category managed by user
resp = app.get('/backoffice/forms/')
assert 'Bar' in resp.text # now there's a form in this category
assert 'form in category' in resp.text
# no access to subdirectories
assert 'href="categories/"' not in resp.text
assert 'href="data-sources/"' not in resp.text
assert 'href="blocks/"' not in resp.text
app.get('/backoffice/forms/categories/', status=403)
app.get('/backoffice/forms/data-sources/', status=403)
app.get('/backoffice/forms/blocks/', status=403)
# no import into other category
formdef_xml = ET.tostring(formdef.export_to_xml(include_id=True))
resp = resp.click(href='import')
resp.forms[0]['file'] = Upload('formdef.wcs', formdef_xml)
resp = resp.forms[0].submit()
assert 'Invalid File (unauthorized category)' in resp.text
# check access to inspect page
formdef.workflow_roles = {'_receiver': int(backoffice_role.id)}
formdef.store()
formdata = formdef.data_class()()
formdata.just_created()
formdata.store()
resp = app.get(formdata.get_backoffice_url())
assert 'inspect' not in resp.text
resp = app.get(formdata.get_backoffice_url() + 'inspect', status=403)
new_formdef.workflow_roles = {'_receiver': int(backoffice_role.id)}
new_formdef.store()
formdata = new_formdef.data_class()()
formdata.just_created()
formdata.store()
resp = app.get(formdata.get_backoffice_url())
assert 'inspect' in resp.text
resp = app.get(formdata.get_backoffice_url() + 'inspect')

View File

@ -1,6 +1,7 @@
import io
import os
import re
import xml.etree.ElementTree as ET
from unittest import mock
import pytest
@ -2824,3 +2825,68 @@ def test_workflows_categories_in_index(pub):
resp = app.get('/backoffice/workflows/')
assert 'Uncategorised' in resp.text
assert 'XcategoryY' in resp.text
def test_workflow_category_management_roles(pub, backoffice_user, backoffice_role):
app = login(get_app(pub), username='backoffice-user', password='backoffice-user')
app.get('/backoffice/workflows/', status=403)
WorkflowCategory.wipe()
cat = WorkflowCategory(name='Foo')
cat.store()
Workflow.wipe()
workflow = Workflow()
workflow.name = 'workflow title'
workflow.category_id = cat.id
workflow.store()
cat = WorkflowCategory(name='Bar')
cat.management_roles = [backoffice_role]
cat.store()
resp = app.get('/backoffice/workflows/')
assert 'Foo' not in resp.text # not a category managed by user
assert 'workflow title' not in resp.text # workflow in that category
assert 'Bar' not in resp.text # not yet any form in this category
resp = resp.click('New Workflow')
resp.forms[0]['name'] = 'workflow in category'
assert len(resp.forms[0]['category_id'].options) == 1 # single option
assert resp.forms[0]['category_id'].value == cat.id # the category managed by user
resp = resp.forms[0].submit().follow()
# check category select only let choose one
resp = resp.click(href='category')
assert len(resp.forms[0]['category_id'].options) == 1 # single option
assert resp.forms[0]['category_id'].value == cat.id # the category managed by user
resp = app.get('/backoffice/workflows/')
assert 'Bar' in resp.text # now there's a form in this category
assert 'workflow in category' in resp.text
# no access to subdirectories
assert 'href="categories/"' not in resp.text
assert 'href="data-sources/"' not in resp.text
assert 'href="mail-templates/"' not in resp.text
app.get('/backoffice/workflows/categories/', status=403)
app.get('/backoffice/workflows/data-sources/', status=403)
app.get('/backoffice/workflows/mail-templates/', status=403)
# no import into other category
workflow_xml = ET.tostring(workflow.export_to_xml(include_id=True))
resp = resp.click(href='import')
resp.forms[0]['file'] = Upload('workflow.wcs', workflow_xml)
resp = resp.forms[0].submit()
assert 'Invalid File (unauthorized category)' in resp.text
# access to default workflows
app.get('/backoffice/workflows/_carddef_default/')
resp = app.get('/backoffice/workflows/_default/')
# duplicate on default workflows should open a dialog
resp = resp.click(href='duplicate')
assert len(resp.forms[0]['category_id'].options) == 1 # single option
resp = resp.forms[0].submit('cancel').follow()
resp = resp.click(href='duplicate')
resp = resp.forms[0].submit('submit').follow()

View File

@ -4,6 +4,8 @@ from unittest import mock
import pytest
from wcs.qommon.ident.password_accounts import PasswordAccount
from .utilities import EmailsMocking, HttpRequestsMocking, SMSMocking
@ -115,3 +117,33 @@ def sql_queries(monkeypatch):
monkeypatch.setattr(psycopg2, 'connect', connect)
yield queries
wcs.sql.cleanup_connection()
@pytest.fixture
def backoffice_role(pub):
role = pub.role_class.get_on_index('backoffice-role', 'slug', ignore_errors=True)
if not role:
role = pub.role_class(name='backoffice role')
role.allows_backoffice_access = True
role.store()
assert role.slug == 'backoffice-role'
return role
@pytest.fixture
def backoffice_user(pub, backoffice_role):
try:
user = pub.user_class.get_users_with_email('backoffice-user@example.net')[0]
except IndexError:
user = pub.user_class()
user.name = 'backoffice user'
user.email = 'backoffice-user@example.net'
user.roles = [backoffice_role.id]
user.store()
account1 = PasswordAccount(id='backoffice-user')
account1.set_password('backoffice-user')
account1.user_id = user.id
account1.store()
return user

View File

@ -24,7 +24,7 @@ from wcs.backoffice.snapshots import SnapshotsDirectory
from wcs.blocks import BlockDef, BlockdefImportError
from wcs.qommon import _, misc, template
from wcs.qommon.backoffice.menu import html_top
from wcs.qommon.errors import TraversalError
from wcs.qommon.errors import AccessForbiddenError, TraversalError
from wcs.qommon.form import FileWidget, Form, HtmlWidget, StringWidget
@ -195,6 +195,8 @@ class BlocksDirectory(Directory):
self.section = section
def _q_traverse(self, path):
if not get_publisher().get_backoffice_root().is_global_accessible('forms'):
raise AccessForbiddenError()
get_response().breadcrumb.append(('blocks/', _('Fields Blocks')))
return super()._q_traverse(path)

View File

@ -23,17 +23,21 @@ from wcs.categories import CardDefCategory, Category, WorkflowCategory
from wcs.formdef import FormDef
from wcs.qommon import _, misc, template
from wcs.qommon.backoffice.menu import html_top
from wcs.qommon.errors import AccessForbiddenError
from wcs.qommon.form import Form, HtmlWidget, SingleSelectWidget, StringWidget, WidgetList, WysiwygTextWidget
from wcs.workflows import Workflow
def get_categories(category_class):
t = sorted((misc.simplify(x.name), x.id, x.name, x.id) for x in category_class.select())
def get_categories(category_class, filter_function):
t = sorted(
(misc.simplify(x.name), x.id, x.name, x.id) for x in category_class.select() if filter_function(x)
)
return [x[1:] for x in t]
class CategoryUI:
category_class = Category
management_roles_hint_text = _('Roles allowed to create, edit and delete forms.')
def __init__(self, category):
self.category = category
@ -66,6 +70,21 @@ class CategoryUI:
if not new:
# include permission fields
roles = list(get_publisher().role_class.select(order_by='name'))
if 'management_roles' in [x[0] for x in self.category_class.XML_NODES]:
form.add(
WidgetList,
'management_roles',
title=_('Management Roles'),
element_type=SingleSelectWidget,
value=self.category.management_roles,
add_element_label=_('Add Role'),
element_kwargs={
'render_br': False,
'options': [(None, '---', None)]
+ [(x, x.name, x.id) for x in roles if not x.is_internal()],
},
hint=self.management_roles_hint_text,
)
if 'export_roles' in [x[0] for x in self.category_class.XML_NODES]:
form.add(
WidgetList,
@ -79,7 +98,7 @@ class CategoryUI:
'options': [(None, '---', None)]
+ [(x, x.name, x.id) for x in roles if not x.is_internal()],
},
hint=_('Roles allowed to export data'),
hint=_('Roles allowed to export data.'),
)
if 'statistics_roles' in [x[0] for x in self.category_class.XML_NODES]:
form.add(
@ -94,7 +113,7 @@ class CategoryUI:
'options': [(None, '---', None)]
+ [(x, x.name, x.id) for x in roles if not x.is_internal()],
},
hint=_('Roles with access to the statistics page'),
hint=_('Roles with access to the statistics page.'),
)
form.add_submit('submit', _('Submit'))
@ -110,7 +129,13 @@ class CategoryUI:
form.get_widget('name').set_error(_('This name is already used'))
raise ValueError()
for attribute in ('description', 'redirect_url', 'export_roles', 'statistics_roles'):
for attribute in (
'description',
'redirect_url',
'management_roles',
'export_roles',
'statistics_roles',
):
widget = form.get_widget(attribute)
if widget:
setattr(self.category, attribute, widget.parse())
@ -120,10 +145,12 @@ class CategoryUI:
class CardDefCategoryUI(CategoryUI):
category_class = CardDefCategory
management_roles_hint_text = _('Roles allowed to create, edit and delete card models.')
class WorkflowCategoryUI(CategoryUI):
category_class = WorkflowCategory
management_roles_hint_text = _('Roles allowed to create, edit and delete workflows.')
class CategoryPage(Directory):
@ -234,6 +261,8 @@ class WorkflowCategoryPage(CategoryPage):
class CategoriesDirectory(Directory):
_q_exports = ['', 'new', 'update_order']
base_section = 'forms'
category_class = Category
category_ui_class = CategoryUI
category_page_class = CategoryPage
@ -301,11 +330,14 @@ class CategoriesDirectory(Directory):
return self.category_page_class(component)
def _q_traverse(self, path):
if not get_publisher().get_backoffice_root().is_global_accessible(self.base_section):
raise AccessForbiddenError()
get_response().breadcrumb.append(('categories/', _('Categories')))
return super()._q_traverse(path)
class CardDefCategoriesDirectory(CategoriesDirectory):
base_section = 'cards'
category_class = CardDefCategory
category_ui_class = CardDefCategoryUI
category_page_class = CardDefCategoryPage
@ -313,6 +345,7 @@ class CardDefCategoriesDirectory(CategoriesDirectory):
class WorkflowCategoriesDirectory(CategoriesDirectory):
base_section = 'workflows'
category_class = WorkflowCategory
category_ui_class = WorkflowCategoryUI
category_page_class = WorkflowCategoryPage

View File

@ -31,6 +31,7 @@ from wcs.data_sources import (
from wcs.formdef import get_formdefs_of_all_kinds
from wcs.qommon import _, errors, misc, template
from wcs.qommon.backoffice.menu import html_top
from wcs.qommon.errors import AccessForbiddenError
from wcs.qommon.form import (
CheckboxWidget,
DurationWidget,
@ -421,6 +422,12 @@ class NamedDataSourcesDirectory(Directory):
]
def _q_traverse(self, path):
if (
not get_publisher().get_backoffice_root().is_global_accessible('forms')
and not get_publisher().get_backoffice_root().is_global_accessible('workflows')
and not get_publisher().get_backoffice_root().is_global_accessible('cards')
):
raise AccessForbiddenError()
get_response().breadcrumb.append(('data-sources/', _('Data Sources')))
return super()._q_traverse(path)

View File

@ -32,7 +32,7 @@ from wcs.forms.root import qrcode
from wcs.qommon import _, force_str, get_logger, misc, template
from wcs.qommon.afterjobs import AfterJob
from wcs.qommon.backoffice.menu import html_top
from wcs.qommon.errors import TraversalError
from wcs.qommon.errors import AccessForbiddenError, TraversalError
from wcs.qommon.form import (
CheckboxesWidget,
CheckboxWidget,
@ -62,15 +62,29 @@ from .fields import FieldDefPage, FieldsDirectory
from .logged_errors import LoggedErrorsDirectory
def is_global_accessible(section):
return get_publisher().get_backoffice_root().is_global_accessible(section)
class FormDefUI:
formdef_class = FormDef
category_class = Category
section = 'forms'
def __init__(self, formdef):
self.formdef = formdef
def get_categories(self):
return get_categories(self.category_class)
global_access = is_global_accessible(self.section)
user_roles = set(get_request().user.get_roles())
def filter_function(category):
if global_access:
return True
management_roles = {x.id for x in getattr(category, 'management_roles') or []}
return bool(user_roles.intersection(management_roles))
return get_categories(self.category_class, filter_function=filter_function)
@classmethod
def get_workflows(cls, condition=lambda x: True):
@ -87,12 +101,14 @@ class FormDefUI:
form.add(StringWidget, 'name', title=_('Name'), required=True, size=40, value=formdef.name)
categories = self.get_categories()
if categories:
if is_global_accessible(self.section):
categories = [(None, '---', '')] + list(categories)
form.add(
SingleSelectWidget,
'category_id',
title=_('Category'),
value=formdef.category_id,
options=[(None, '---', '')] + categories,
options=categories,
)
workflows = self.get_workflows()
if len(workflows) > 1:
@ -182,6 +198,8 @@ class AdminFieldsDirectory(FieldsDirectory):
class OptionsDirectory(Directory):
category_class = Category
category_empty_choice = _('Select a category for this form')
section = 'forms'
_q_exports = [
'confirmation',
'only_allow_one',
@ -199,9 +217,10 @@ class OptionsDirectory(Directory):
'user_support',
]
def __init__(self, formdef):
def __init__(self, formdef, formdefui):
self.formdef = formdef
self.changed = False
self.formdefui = formdefui
def confirmation(self):
form = Form(enctype='multipart/form-data')
@ -331,7 +350,9 @@ class OptionsDirectory(Directory):
return self.handle(form, _('Keywords'))
def category(self):
categories = get_categories(self.category_class)
categories = self.formdefui.get_categories()
if is_global_accessible(self.section):
categories = [(None, '---', '')] + list(categories)
form = Form(enctype='multipart/form-data')
form.widgets.append(HtmlWidget('<p>%s</p>' % self.category_empty_choice))
form.add(
@ -339,7 +360,7 @@ class OptionsDirectory(Directory):
'category_id',
title=_('Category'),
value=self.formdef.category_id,
options=[(None, '---', '')] + categories,
options=list(categories),
)
return self.handle(form, _('Category'))
@ -572,6 +593,7 @@ class FormDefPage(Directory):
formdef_export_prefix = 'form'
formdef_ui_class = FormDefUI
formdef_default_workflow = '_default'
section = 'forms'
options_directory_class = OptionsDirectory
delete_message = _('You are about to irrevocably delete this form.')
@ -595,14 +617,14 @@ class FormDefPage(Directory):
self.fields.html_top = self.html_top
self.role = WorkflowRoleDirectory(self.formdef)
self.role.html_top = self.html_top
self.options = self.options_directory_class(self.formdef)
self.options = self.options_directory_class(self.formdef, self.formdefui)
self.logged_errors_dir = LoggedErrorsDirectory(
parent_dir=self, formdef_class=self.formdef_class, formdef_id=self.formdef.id
)
self.snapshots_dir = SnapshotsDirectory(self.formdef)
def html_top(self, title):
return html_top('forms', title)
return html_top(self.section, title)
def add_option_line(self, link, label, current_value, popup=True):
return htmltext(
@ -1681,6 +1703,7 @@ class FormsDirectory(AccessControlled, Directory):
formdef_page_class = FormDefPage
formdef_ui_class = FormDefUI
section = 'forms'
top_title = _('Forms')
import_title = _('Import Form')
import_submit_label = _('Import Form')
@ -1696,23 +1719,43 @@ class FormsDirectory(AccessControlled, Directory):
)
def html_top(self, title):
return html_top('forms', title)
return html_top(self.section, title)
def _q_traverse(self, path):
get_response().breadcrumb.append(('forms/', _('Forms')))
get_response().breadcrumb.append(('%s/' % self.section, self.top_title))
return super()._q_traverse(path)
def is_accessible(self, user):
if is_global_accessible(self.section):
return True
# check for access to specific categories
user_roles = set(user.get_roles())
for category in self.category_class.select():
management_roles = {x.id for x in getattr(category, 'management_roles') or []}
if management_roles and user_roles.intersection(management_roles):
return True
return False
def _q_index(self):
self.html_top(title=self.top_title)
r = TemplateIO(html=True)
get_response().add_javascript(['jquery.js', 'widget_list.js'])
r += self.form_actions()
global_access = is_global_accessible(self.section)
cats = self.category_class.select()
self.category_class.sort_by_position(cats)
one = False
formdefs = self.formdef_class.select(order_by='name', ignore_errors=True, lightweight=True)
for c in cats:
if not global_access:
user_roles = set(get_request().user.get_roles())
management_roles = {x.id for x in getattr(c, 'management_roles') or []}
if not user_roles.intersection(management_roles):
continue
l2 = [x for x in formdefs if str(x.category_id) == str(c.id)]
l2 = [x for x in l2 if not x.disabled or (x.disabled and x.disabled_redirection)] + [
x for x in l2 if x.disabled and not x.disabled_redirection
@ -1721,16 +1764,17 @@ class FormsDirectory(AccessControlled, Directory):
r += self.form_list(l2, title=c.name)
one = True
l2 = [x for x in formdefs if not x.category]
if l2:
if one:
title = _('Misc')
else:
title = None
l2 = [x for x in l2 if not x.disabled or (x.disabled and x.disabled_redirection)] + [
x for x in l2 if x.disabled and not x.disabled_redirection
]
r += self.form_list(l2, title=title)
if global_access:
l2 = [x for x in formdefs if not x.category]
if l2:
if one:
title = _('Misc')
else:
title = None
l2 = [x for x in l2 if not x.disabled or (x.disabled and x.disabled_redirection)] + [
x for x in l2 if x.disabled and not x.disabled_redirection
]
r += self.form_list(l2, title=title)
return r.getvalue()
def form_actions(self):
@ -1740,11 +1784,12 @@ class FormsDirectory(AccessControlled, Directory):
r += htmltext('<h2>%s</h2>') % _('Forms')
if has_roles:
r += htmltext('<span class="actions">')
r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
if get_publisher().has_site_option('fields-blocks'):
r += htmltext('<a href="blocks/">%s</a>') % _('Fields blocks')
if get_publisher().get_backoffice_root().is_accessible('categories'):
r += htmltext('<a href="categories/">%s</a>') % _('Categories')
if is_global_accessible('forms'):
r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
if get_publisher().has_site_option('fields-blocks'):
r += htmltext('<a href="blocks/">%s</a>') % _('Fields blocks')
if get_publisher().get_backoffice_root().is_accessible('categories'):
r += htmltext('<a href="categories/">%s</a>') % _('Categories')
r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
r += htmltext('<a class="new-item" href="new" rel="popup">%s</a>') % _('New Form')
r += htmltext('</span>')
@ -1777,7 +1822,7 @@ class FormsDirectory(AccessControlled, Directory):
def new(self):
get_response().breadcrumb.append(('new', _('New')))
if get_publisher().role_class.count() == 0:
return template.error_page('forms', _('You first have to define roles.'))
return template.error_page(self.section, _('You first have to define roles.'))
formdefui = self.formdef_ui_class(None)
form = formdefui.new_form_ui()
if form.get_widget('cancel').parse():
@ -1800,7 +1845,18 @@ class FormsDirectory(AccessControlled, Directory):
return r.getvalue()
def _q_lookup(self, component):
return self.formdef_page_class(component)
directory = self.formdef_page_class(component)
global_access = is_global_accessible(self.section)
if not global_access:
user_roles = set(get_request().user.get_roles())
management_roles = set()
if directory.formdef.category:
management_roles = {
x.id for x in getattr(directory.formdef.category, 'management_roles') or []
}
if not management_roles.intersection(user_roles):
raise AccessForbiddenError()
return directory
def p_import(self):
form = Form(enctype='multipart/form-data')
@ -1859,6 +1915,14 @@ class FormsDirectory(AccessControlled, Directory):
except ValueError:
error = True
global_access = is_global_accessible(self.section)
if not global_access:
management_roles = {x.id for x in getattr(formdef.category, 'management_roles', None) or []}
user_roles = set(get_request().user.get_roles())
if not user_roles.intersection(management_roles):
error = True
reason = _('unauthorized category')
if error:
if reason:
msg = _('Invalid File (%s)') % reason

View File

@ -40,6 +40,8 @@ class MailTemplatesDirectory(Directory):
do_not_call_in_templates = True
def _q_traverse(self, path):
if not get_publisher().get_backoffice_root().is_global_accessible('workflows'):
raise errors.AccessForbiddenError()
get_response().breadcrumb.append(('mail-templates/', _('Mail Templates')))
return super()._q_traverse(path)

View File

@ -66,6 +66,10 @@ from .logged_errors import LoggedErrorsDirectory
from .mail_templates import MailTemplatesDirectory
def is_global_accessible():
return get_publisher().get_backoffice_root().is_global_accessible('workflows')
def svg(tag):
return '{http://www.w3.org/2000/svg}%s' % tag
@ -295,16 +299,30 @@ class WorkflowUI:
def __init__(self, workflow):
self.workflow = workflow
def get_categories(self):
global_access = is_global_accessible()
user_roles = set(get_request().user.get_roles())
def filter_function(category):
if global_access:
return True
management_roles = {x.id for x in getattr(category, 'management_roles') or []}
return bool(user_roles.intersection(management_roles))
return get_categories(WorkflowCategory, filter_function=filter_function)
def form_new(self):
form = Form(enctype='multipart/form-data')
form.add(StringWidget, 'name', title=_('Workflow Name'), required=True, size=30)
category_options = get_categories(WorkflowCategory)
category_options = self.get_categories()
if category_options:
if is_global_accessible():
category_options = [(None, '---', '')] + list(category_options)
form.add(
SingleSelectWidget,
'category_id',
title=_('Category'),
options=[(None, '---', '')] + category_options,
options=category_options,
)
form.add_submit('submit', _('Submit'))
form.add_submit('cancel', _('Cancel'))
@ -332,8 +350,10 @@ class WorkflowUI:
form.get_widget('name').set_error(_('This name is already used'))
raise ValueError()
for f in ('name',):
setattr(workflow, f, form.get_widget(f).parse())
for f in ('name', 'category_id'):
widget = form.get_widget(f)
if widget:
setattr(workflow, f, widget.parse())
workflow.store()
return workflow
@ -1487,7 +1507,9 @@ class WorkflowPage(Directory):
return html_top('workflows', title)
def category(self):
categories = sorted((misc.simplify(x.name), x.id, x.name, x.id) for x in WorkflowCategory.select())
category_options = self.workflow_ui.get_categories()
if is_global_accessible():
category_options = [(None, '---', '')] + list(category_options)
form = Form(enctype='multipart/form-data')
form.widgets.append(HtmlWidget('<p>%s</p>' % _('Select a category for this workflow.')))
@ -1496,7 +1518,7 @@ class WorkflowPage(Directory):
'category_id',
title=_('Category'),
value=self.workflow.category_id,
options=[(None, '---', '')] + [x[1:] for x in categories],
options=category_options,
)
if not self.workflow.is_readonly():
@ -1560,7 +1582,10 @@ class WorkflowPage(Directory):
r += htmltext('<ul id="sidebar-actions">')
if not self.workflow.is_readonly():
r += htmltext('<li><a href="delete" rel="popup">%s</a></li>') % _('Delete')
r += htmltext('<li><a href="duplicate">%s</a></li>') % _('Duplicate')
if not is_global_accessible() and self.workflow.id in ('_default', '_carddef_default'):
r += htmltext('<li><a rel="popup" href="duplicate">%s</a></li>') % _('Duplicate')
else:
r += htmltext('<li><a href="duplicate">%s</a></li>') % _('Duplicate')
r += htmltext('<li><a href="export">%s</a></li>') % _('Export')
if get_publisher().snapshot_class:
r += htmltext('<li><a rel="popup" href="history/save">%s</a></li>') % _('Save snapshot')
@ -1746,7 +1771,7 @@ class WorkflowPage(Directory):
return redirect('.')
def edit(self, duplicate=False):
def edit(self):
form = self.workflow_ui.form_edit()
if form.get_widget('cancel').parse():
return redirect('.')
@ -1761,12 +1786,8 @@ class WorkflowPage(Directory):
self.html_top(title=_('Edit Workflow'))
r = TemplateIO(html=True)
if duplicate:
get_response().breadcrumb.append(('edit', _('Duplicate')))
r += htmltext('<h2>%s</h2>') % _('Duplicate Workflow')
else:
get_response().breadcrumb.append(('edit', _('Edit')))
r += htmltext('<h2>%s</h2>') % _('Edit Workflow')
get_response().breadcrumb.append(('edit', _('Edit')))
r += htmltext('<h2>%s</h2>') % _('Edit Workflow')
r += form.render()
return r.getvalue()
@ -1801,6 +1822,31 @@ class WorkflowPage(Directory):
return redirect('..')
def duplicate(self):
if not is_global_accessible() and self.workflow.id in ('_default', '_carddef_default'):
category_options = self.workflow_ui.get_categories()
form = Form(enctype='multipart/form-data')
form.widgets.append(HtmlWidget('<p>%s</p>' % _('Select a category for this workflow.')))
form.add(
SingleSelectWidget,
'category_id',
title=_('Category'),
options=category_options,
)
form.add_submit('submit', _('Submit'))
form.add_submit('cancel', _('Cancel'))
if form.get_widget('cancel').parse():
return redirect('.')
if not form.is_submitted() or form.has_errors():
self.html_top(title=_('Duplicate Workflow'))
r = TemplateIO(html=True)
get_response().breadcrumb.append(('duplicate', _('Duplicate')))
r += htmltext('<h2>%s</h2>') % _('Duplicate Workflow')
r += form.render()
return r.getvalue()
self.workflow_ui.workflow.category_id = form.get_widget('category_id').parse()
self.workflow_ui.workflow.id = None
original_name = self.workflow_ui.workflow.name
self.workflow_ui.workflow.name = '%s %s' % (self.workflow_ui.workflow.name, _('(copy)'))
@ -1842,6 +1888,19 @@ class WorkflowsDirectory(Directory):
get_response().breadcrumb.append(('workflows/', _('Workflows')))
return super()._q_traverse(path)
def is_accessible(self, user):
if is_global_accessible():
return True
# check for access to specific categories
user_roles = set(user.get_roles())
for category in WorkflowCategory.select():
management_roles = {x.id for x in getattr(category, 'management_roles') or []}
if management_roles and user_roles.intersection(management_roles):
return True
return False
def _q_index(self):
self.html_top(title=_('Workflows'))
r = TemplateIO(html=True)
@ -1849,10 +1908,10 @@ class WorkflowsDirectory(Directory):
r += htmltext('<div id="appbar">')
r += htmltext('<h2>%s</h2>') % _('Workflows')
r += htmltext('<span class="actions">')
if get_publisher().has_site_option('mail-templates'):
r += htmltext('<a href="mail-templates/">%s</a>') % _('Mail Templates')
r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
if get_publisher().get_backoffice_root().is_accessible('categories'):
if is_global_accessible():
if get_publisher().has_site_option('mail-templates'):
r += htmltext('<a href="mail-templates/">%s</a>') % _('Mail Templates')
r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
r += htmltext('<a href="categories/">%s</a>') % _('Categories')
r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
r += htmltext('<a class="new-item" rel="popup" href="new">%s</a>') % _('New Workflow')
@ -1889,7 +1948,16 @@ class WorkflowsDirectory(Directory):
else:
unused_workflows.append(workflow)
categories = self.category_class.select()
if is_global_accessible():
categories = WorkflowCategory.select()
else:
categories = []
user_roles = set(get_request().user.get_roles())
for category in WorkflowCategory.select():
management_roles = {x.id for x in getattr(category, 'management_roles') or []}
if management_roles and user_roles.intersection(management_roles):
categories.append(category)
self.category_class.sort_by_position(categories)
if categories:
@ -1900,6 +1968,9 @@ class WorkflowsDirectory(Directory):
workflow.category_id = default_category.id
categories = [default_category] + categories
if is_global_accessible():
categories = categories + [None]
def workflow_section(r, workflows):
r += htmltext('<ul class="objects-list single-links">')
for workflow in workflows:
@ -1925,7 +1996,7 @@ class WorkflowsDirectory(Directory):
r += htmltext('</li>')
r += htmltext('</ul>')
for category in categories + [None]:
for category in categories:
if category is None:
category_workflows = [x for x in workflows + unused_workflows if not x.category_id]
else:
@ -1970,7 +2041,18 @@ class WorkflowsDirectory(Directory):
return r.getvalue()
def _q_lookup(self, component):
return WorkflowPage(component)
directory = WorkflowPage(component)
global_access = is_global_accessible()
if directory.workflow.id not in ('_default', '_carddef_default') and not global_access:
user_roles = set(get_request().user.get_roles())
management_roles = set()
if directory.workflow.category:
management_roles = {
x.id for x in getattr(directory.workflow.category, 'management_roles') or []
}
if not management_roles.intersection(user_roles):
raise errors.AccessForbiddenError()
return directory
def p_import(self):
form = Form(enctype='multipart/form-data')
@ -2024,6 +2106,14 @@ class WorkflowsDirectory(Directory):
except ValueError:
error = True
global_access = is_global_accessible()
if not global_access:
management_roles = {x.id for x in getattr(workflow.category, 'management_roles', None) or []}
user_roles = set(get_request().user.get_roles())
if not user_roles.intersection(management_roles):
error = True
reason = _('unauthorized category')
if error:
if reason:
msg = _('Invalid File (%s)') % reason

View File

@ -15,12 +15,11 @@
# along with this program; if not, see <http://www.gnu.org/licenses/>.
from quixote import get_publisher, get_response, get_session, redirect
from quixote.directory import Directory
from quixote.html import TemplateIO, htmltext
from wcs.admin import utils
from wcs.admin.categories import CardDefCategoriesDirectory
from wcs.admin.forms import FormDefPage, FormDefUI, FormsDirectory, OptionsDirectory, html_top
from wcs.admin.forms import FormDefPage, FormDefUI, FormsDirectory, OptionsDirectory
from wcs.carddef import CardDef
from wcs.categories import CardDefCategory
from wcs.workflows import Workflow
@ -33,11 +32,13 @@ from ..qommon.storage import NotEqual, Null
class CardDefUI(FormDefUI):
formdef_class = CardDef
category_class = CardDefCategory
section = 'cards'
class CardDefOptionsDirectory(OptionsDirectory):
category_class = CardDefCategory
category_empty_choice = _('Select a category for this card model')
section = 'cards'
class CardDefPage(FormDefPage):
@ -45,6 +46,7 @@ class CardDefPage(FormDefPage):
formdef_export_prefix = 'card'
formdef_ui_class = CardDefUI
formdef_default_workflow = '_carddef_default'
section = 'cards'
options_directory_class = CardDefOptionsDirectory
@ -60,9 +62,6 @@ class CardDefPage(FormDefPage):
readonly_message = _('This card model is readonly.')
management_view_label = _('List of cards')
def html_top(self, title):
return html_top('cards', title)
def _q_index(self):
self.html_top(title=self.formdef.name)
r = TemplateIO(html=True)
@ -234,6 +233,7 @@ class CardsDirectory(FormsDirectory):
formdef_page_class = CardDefPage
formdef_ui_class = CardDefUI
section = 'cards'
top_title = _('Card Models')
import_title = _('Import Card Model')
import_submit_label = _('Import Card Model')
@ -247,22 +247,17 @@ class CardsDirectory(FormsDirectory):
'you should nevertheless check everything is ok. '
)
def html_top(self, title):
return html_top('cards', title)
def _q_traverse(self, path):
get_response().breadcrumb.append(('cards/', _('Card Models')))
return Directory._q_traverse(self, path)
def form_actions(self):
r = TemplateIO(html=True)
r += htmltext('<div id="appbar">')
r += htmltext('<h2>%s</h2>') % _('Card Models')
r += htmltext('<span class="actions">')
r += htmltext('<a href="../forms/data-sources/">%s</a>') % _('Data sources')
if get_publisher().has_site_option('fields-blocks'):
r += htmltext('<a href="../forms/blocks/">%s</a>') % _('Fields blocks')
r += htmltext('<a href="categories/">%s</a>') % _('Categories')
if get_publisher().get_backoffice_root().is_global_accessible('forms'):
r += htmltext('<a href="../forms/data-sources/">%s</a>') % _('Data sources')
if get_publisher().has_site_option('fields-blocks'):
r += htmltext('<a href="../forms/blocks/">%s</a>') % _('Fields blocks')
if get_publisher().get_backoffice_root().is_global_accessible('cards'):
r += htmltext('<a href="categories/">%s</a>') % _('Categories')
r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
r += htmltext('<a class="new-item" href="new" rel="popup">%s</a>') % _('New Card Model')
r += htmltext('</span>')

View File

@ -2577,6 +2577,25 @@ class FormBackOfficeStatusPage(FormStatusPage):
return response
def can_go_in_inspector(self):
if get_publisher().get_backoffice_root().is_global_accessible('worflows'):
return True
if (
get_publisher()
.get_backoffice_root()
.is_global_accessible(self.formdata.formdef.backoffice_section)
):
return True
user_roles = set(get_request().user.get_roles())
for category in (self.formdata.formdef.category, self.formdata.formdef.workflow.category):
if not category:
continue
management_roles = {x.id for x in getattr(category, 'management_roles') or []}
if user_roles.intersection(management_roles):
return True
return False
def get_extra_context_bar(self, parent=None):
formdata = self.filled
@ -2686,10 +2705,7 @@ class FormBackOfficeStatusPage(FormStatusPage):
'<div data-async-url="%suser-pending-forms"></div>' % formdata.get_url(backoffice=True)
)
if not formdata.is_draft() and (
get_publisher().get_backoffice_root().is_accessible('forms')
or get_publisher().get_backoffice_root().is_accessible('workflows')
):
if not formdata.is_draft() and self.can_go_in_inspector():
r += htmltext('<div class="extra-context">')
r += htmltext('<p><a href="%sinspect">' % formdata.get_url(backoffice=True))
r += htmltext('%s</a></p>') % _('Data Inspector')
@ -3065,10 +3081,7 @@ class FormBackOfficeStatusPage(FormStatusPage):
return r.getvalue()
def inspect(self):
if not (
get_publisher().get_backoffice_root().is_accessible('forms')
or get_publisher().get_backoffice_root().is_accessible('workflows')
):
if not self.can_go_in_inspector():
raise errors.AccessForbiddenError()
charset = get_publisher().site_charset
get_response().breadcrumb.append(('inspect', _('Data Inspector')))

View File

@ -98,21 +98,28 @@ class RootDirectory(BackofficeRootDirectory):
return subdirectory in ('settings', 'users')
return False
# if the directory defines a is_accessible method, use it.
if hasattr(getattr(cls, subdirectory, None), 'is_accessible'):
return getattr(cls, subdirectory).is_accessible(get_request().user)
return cls.is_global_accessible(subdirectory)
@classmethod
def is_global_accessible(cls, subdirectory):
if cls.check_admin_for_all():
return True
user_roles = set(get_request().user.get_roles())
authorised_roles = set(get_cfg('admin-permissions', {}).get(subdirectory) or [])
if authorised_roles:
# access is governed by roles set in the settings panel
return user_roles.intersection(authorised_roles)
# if the directory defines a is_accessible method, use it.
if hasattr(getattr(cls, subdirectory, None), 'is_accessible'):
return getattr(cls, subdirectory).is_accessible(get_request().user)
# as a last resort, for the other directories, the user needs to be
# marked as admin
return get_request().user.can_go_in_admin()
def check_admin_for_all(self):
@classmethod
def check_admin_for_all(cls):
admin_for_all_file_path = os.path.join(get_publisher().app_dir, 'ADMIN_FOR_ALL')
if not os.path.exists(os.path.join(admin_for_all_file_path)):
return False

View File

@ -33,6 +33,7 @@ if not hasattr(types, 'ClassType'):
class CardDef(FormDef):
_names = 'carddefs'
backoffice_section = 'cards'
data_sql_prefix = 'carddata'
pickle_module_name = 'carddef'
xml_root_node = 'carddef'

View File

@ -35,6 +35,7 @@ class Category(XmlStorableObject):
export_roles = None
statistics_roles = None
management_roles = None
# declarations for serialization
XML_NODES = [
@ -45,6 +46,7 @@ class Category(XmlStorableObject):
('position', 'int'),
('export_roles', 'roles'),
('statistics_roles', 'roles'),
('management_roles', 'roles'),
]
def __init__(self, name=None):
@ -149,6 +151,7 @@ class CardDefCategory(Category):
('description', 'str'),
('position', 'int'),
('export_roles', 'roles'),
('management_roles', 'roles'),
]
@classmethod
@ -168,6 +171,7 @@ class WorkflowCategory(Category):
('url_name', 'str'),
('description', 'str'),
('position', 'int'),
('management_roles', 'roles'),
]
@classmethod

View File

@ -85,6 +85,7 @@ class FormDef(StorableObject):
data_sql_prefix = 'formdata'
pickle_module_name = 'formdef'
xml_root_node = 'formdef'
backoffice_section = 'forms'
verbose_name = _('Form')
verbose_name_plural = _('Forms')

View File

@ -30,11 +30,18 @@
{% endwith %}
</div>
{% if category.export_roles or category.statistics_roles %}
{% if category.export_roles or category.statistics_roles or category.management_roles %}
<div class="section">
<h3>{% trans "Permissions" %}</h3>
<div>
<ul>
{% if category.management_roles %}
<li>{% trans "Management roles:" %}
<ul>
{% for role in category.management_roles %}<li>{{ role.name }}</li>{% endfor %}
</ul>
</li>
{% endif %}
{% if category.export_roles %}
<li>{% trans "Export roles:" %}
<ul>