general: add categories for workflows (#55945)

This commit is contained in:
Frédéric Péters 2021-08-03 16:09:16 +02:00
parent bc90b08f5c
commit 16604d04a9
9 changed files with 269 additions and 41 deletions

View File

@ -9,6 +9,7 @@ from webtest import Upload
from wcs import fields
from wcs.carddef import CardDef
from wcs.categories import WorkflowCategory
from wcs.formdef import FormDef
from wcs.mail_templates import MailTemplate
from wcs.qommon.errors import ConnectionError
@ -176,6 +177,45 @@ def test_workflows_edit(pub):
assert 'baz' in resp.text
def test_workflows_category(pub):
create_superuser(pub)
WorkflowCategory.wipe()
Workflow.wipe()
workflow = Workflow(name='foo')
workflow.store()
app = login(get_app(pub))
resp = app.get('/backoffice/workflows/categories/')
resp = resp.click('New Category')
resp.forms[0]['name'] = 'a new category'
resp.forms[0]['description'] = 'description of the category'
resp = resp.forms[0].submit('submit')
assert WorkflowCategory.get(1).name == 'a new category'
resp = app.get('/backoffice/workflows/1/')
resp = resp.click(href='category')
resp.forms[0]['category_id'] = '1'
resp = resp.forms[0].submit('cancel').follow()
workflow.refresh_from_storage()
assert workflow.category_id is None
resp = app.get('/backoffice/workflows/1/')
resp = resp.click(href='category')
resp.forms[0]['category_id'] = '1'
resp = resp.forms[0].submit('submit').follow()
workflow.refresh_from_storage()
assert str(workflow.category_id) == '1'
resp = app.get('/backoffice/workflows/categories/')
resp = resp.click('a new category')
resp = resp.click('Delete')
resp = resp.forms[0].submit()
workflow.refresh_from_storage()
assert not workflow.category_id
def test_workflows_edit_status(pub):
create_superuser(pub)
Workflow.wipe()
@ -2592,26 +2632,54 @@ def test_workflows_unused(pub):
Workflow.wipe()
app = login(get_app(pub))
resp = app.get('/backoffice/workflows/')
assert 'Unused workflows' not in resp.text
assert 'Unused' not in resp.text
workflow = Workflow(name='Workflow One')
workflow.store()
resp = app.get('/backoffice/workflows/')
assert 'Unused workflows' in resp.text
assert 'Unused' in resp.text
formdef = FormDef()
formdef.name = 'form title'
formdef.fields = []
formdef.store()
resp = app.get('/backoffice/workflows/')
assert 'Unused workflows' in resp.text
assert 'Unused' in resp.text
formdef.workflow = workflow
formdef.store()
resp = app.get('/backoffice/workflows/')
assert 'Unused workflows' not in resp.text
assert 'Unused' not in resp.text
workflow = Workflow(name='Workflow Two')
workflow.store()
resp = app.get('/backoffice/workflows/')
assert 'Unused workflows' in resp.text
assert 'Unused' in resp.text
def test_workflows_categories_in_index(pub):
create_superuser(pub)
FormDef.wipe()
Workflow.wipe()
WorkflowCategory.wipe()
wf1 = Workflow(name='wf1')
wf1.store()
wf2 = Workflow(name='wf2')
wf2.store()
app = login(get_app(pub))
resp = app.get('/backoffice/workflows/')
assert 'Uncategorised' not in resp.text
cat = WorkflowCategory(name='XcategoryY')
cat.store()
resp = app.get('/backoffice/workflows/')
assert 'Uncategorised' in resp.text
assert 'XcategoryY' not in resp.text
wf2.category_id = cat.id
wf2.store()
resp = app.get('/backoffice/workflows/')
assert 'Uncategorised' in resp.text
assert 'XcategoryY' in resp.text

View File

@ -5,6 +5,7 @@ import pytest
from quixote.http_request import Upload
from wcs.carddef import CardDef
from wcs.categories import WorkflowCategory
from wcs.fields import FileField, StringField
from wcs.formdef import FormDef
from wcs.mail_templates import MailTemplate
@ -978,3 +979,14 @@ def test_unknown_data_source(pub):
export = ET.tostring(export_to_indented_xml(wf))
with pytest.raises(WorkflowImportError, match='Unknown datasources'):
Workflow.import_from_xml(io.BytesIO(export))
def test_worklow_with_category(pub):
category = WorkflowCategory(name='test category')
category.store()
wf = Workflow(name='test category')
wf.category_id = category.id
wf.store()
wf2 = assert_import_export_works(wf, include_id=True)
assert wf2.category_id == wf.category_id

View File

@ -19,11 +19,17 @@ from quixote.directory import Directory
from quixote.html import TemplateIO, htmltext
from wcs.carddef import CardDef
from wcs.categories import CardDefCategory, Category
from wcs.categories import CardDefCategory, Category, WorkflowCategory
from wcs.formdef import FormDef
from wcs.qommon import _, template
from wcs.qommon import _, misc, template
from wcs.qommon.backoffice.menu import html_top
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())
return [x[1:] for x in t]
class CategoryUI:
@ -60,21 +66,22 @@ class CategoryUI:
if not new:
# include permission fields
roles = list(get_publisher().role_class.select(order_by='name'))
form.add(
WidgetList,
'export_roles',
title=_('Export Roles'),
element_type=SingleSelectWidget,
value=self.category.export_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=_('Roles allowed to export data'),
)
if self.category_class is Category:
if 'export_roles' in [x[0] for x in self.category_class.XML_NODES]:
form.add(
WidgetList,
'export_roles',
title=_('Export Roles'),
element_type=SingleSelectWidget,
value=self.category.export_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=_('Roles allowed to export data'),
)
if 'statistics_roles' in [x[0] for x in self.category_class.XML_NODES]:
form.add(
WidgetList,
'statistics_roles',
@ -115,10 +122,14 @@ class CardDefCategoryUI(CategoryUI):
category_class = CardDefCategory
class WorkflowCategoryUI(CategoryUI):
category_class = WorkflowCategory
class CategoryPage(Directory):
category_class = Category
category_ui_class = CategoryUI
formdef_class = FormDef
object_class = FormDef
usage_title = _('Forms in this category')
empty_message = _('No form associated to this category.')
_q_exports = ['', 'edit', 'delete', 'description']
@ -138,7 +149,7 @@ class CategoryPage(Directory):
)
def get_formdefs(self):
formdefs = self.formdef_class.select(order_by='name')
formdefs = self.object_class.select(order_by='name')
return [x for x in formdefs if x.category_id == self.category.id]
def edit(self):
@ -208,11 +219,19 @@ class CategoryPage(Directory):
class CardDefCategoryPage(CategoryPage):
category_class = CardDefCategory
category_ui_class = CardDefCategoryUI
formdef_class = CardDef
object_class = CardDef
usage_title = _('Card models in this category')
empty_message = _('No card model associated to this category.')
class WorkflowCategoryPage(CategoryPage):
category_class = WorkflowCategory
category_ui_class = WorkflowCategoryUI
object_class = Workflow
usage_title = _('Workflows in this category')
empty_message = _('No workflow associated to this category.')
class CategoriesDirectory(Directory):
_q_exports = ['', 'new', 'update_order']
category_class = Category
@ -291,3 +310,10 @@ class CardDefCategoriesDirectory(CategoriesDirectory):
category_ui_class = CardDefCategoryUI
category_page_class = CardDefCategoryPage
category_explanation = _('Categories are used to sort the different card models.')
class WorkflowCategoriesDirectory(CategoriesDirectory):
category_class = WorkflowCategory
category_ui_class = WorkflowCategoryUI
category_page_class = WorkflowCategoryPage
category_explanation = _('Categories are used to sort the different workflows.')

View File

@ -56,17 +56,12 @@ from wcs.workflows import Workflow
from . import utils
from .blocks import BlocksDirectory
from .categories import CategoriesDirectory
from .categories import CategoriesDirectory, get_categories
from .data_sources import NamedDataSourcesDirectory
from .fields import FieldDefPage, FieldsDirectory
from .logged_errors import LoggedErrorsDirectory
def get_categories(category_class):
t = sorted((misc.simplify(x.name), x.id, x.name, x.id) for x in category_class.select())
return [x[1:] for x in t]
class FormDefUI:
formdef_class = FormDef
category_class = Category

View File

@ -25,9 +25,11 @@ from quixote import get_publisher, get_request, get_response, get_session, redir
from quixote.directory import Directory
from quixote.html import TemplateIO, htmltext
from wcs.admin.categories import WorkflowCategoriesDirectory, get_categories
from wcs.backoffice.snapshots import SnapshotsDirectory
from wcs.backoffice.studio import StudioDirectory
from wcs.carddef import CardDef
from wcs.categories import WorkflowCategory
from wcs.formdata import Evolution
from wcs.formdef import FormDef
from wcs.qommon import _, errors, force_str, get_logger, misc, template
@ -296,6 +298,14 @@ class WorkflowUI:
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)
if category_options:
form.add(
SingleSelectWidget,
'category_id',
title=_('Category'),
options=[(None, '---', '')] + category_options,
)
form.add_submit('submit', _('Submit'))
form.add_submit('cancel', _('Cancel'))
return form
@ -1430,6 +1440,7 @@ class WorkflowPage(Directory):
_q_exports = [
'',
'edit',
'category',
'delete',
'newstatus',
('status', 'status_dir'),
@ -1475,6 +1486,40 @@ class WorkflowPage(Directory):
def html_top(self, title):
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())
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'),
value=self.workflow.category_id,
options=[(None, '---', '')] + [x[1:] for x in categories],
)
if not self.workflow.is_readonly():
form.add_submit('submit', _('Submit'))
form.add_submit('cancel', _('Cancel'))
if form.get_widget('cancel').parse():
return redirect('..')
if form.is_submitted() and not form.has_errors():
widget = form.get_widget('category_id')
old_value = self.workflow.category_id
new_value = widget.parse()
if new_value != old_value:
self.workflow.category_id = new_value
self.workflow.store(comment=_('Changed category'))
return redirect('.')
html_top('workflows', title=self.workflow.name)
r = TemplateIO(html=True)
r += htmltext('<h2>%s</h2>') % _('Category')
r += form.render()
return r.getvalue()
def last_modification_block(self):
return utils.last_modification_block(obj=self.workflow)
@ -1779,6 +1824,7 @@ class WorkflowsDirectory(Directory):
_q_exports = [
'',
'new',
'categories',
('import', 'p_import'),
('data-sources', 'data_sources'),
('mail-templates', 'mail_templates'),
@ -1786,6 +1832,8 @@ class WorkflowsDirectory(Directory):
data_sources = NamedDataSourcesDirectoryInWorkflows()
mail_templates = MailTemplatesDirectory()
category_class = WorkflowCategory
categories = WorkflowCategoriesDirectory()
def html_top(self, title):
return html_top('workflows', title)
@ -1804,6 +1852,8 @@ class WorkflowsDirectory(Directory):
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'):
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')
r += htmltext('</span>')
@ -1839,8 +1889,18 @@ class WorkflowsDirectory(Directory):
else:
unused_workflows.append(workflow)
categories = sorted(WorkflowCategory.select(), key=lambda x: misc.simplify(x.name))
if categories:
default_category = WorkflowCategory('Default')
default_category.id = '_default_category'
for workflow in workflows:
if workflow.id in ('_default', '_carddef_default'):
workflow.category_id = default_category.id
categories = [default_category] + categories
def workflow_section(r, workflows):
r += htmltext('<div class="bo-block"><ul class="biglist">')
r += htmltext('<ul class="objects-list single-links">')
for workflow in workflows:
if workflow in shared_workflows:
css_class = 'shared-workflow'
@ -1853,21 +1913,36 @@ class WorkflowsDirectory(Directory):
usage_label = _('Card models')
else:
css_class = 'unused-workflow'
usage_label = None
usage_label = _('Unused')
r += htmltext('<li class="%s">' % css_class)
r += htmltext('<strong class="label"><a href="%s/">%s</a></strong>') % (
r += htmltext('<a href="%s/">%s</a>') % (
workflow.id,
workflow.name,
)
if usage_label and carddef_workflows:
r += htmltext('<p class="details badge">%s</p>') % usage_label
r += htmltext('<span class="badge">%s</span>') % usage_label
r += htmltext('</li>')
r += htmltext('</ul></div>')
r += htmltext('</ul>')
workflow_section(r, workflows)
if unused_workflows:
r += htmltext('<h2>%s</h2>') % _('Unused workflows')
workflow_section(r, unused_workflows)
for category in categories + [None]:
if category is None:
category_workflows = [x for x in workflows + unused_workflows if not x.category_id]
else:
category_workflows = [
x for x in workflows + unused_workflows if x.category_id == str(category.id)
]
if category_workflows:
if len(categories) > 1:
r += htmltext('<div class="section">')
if category is None:
r += htmltext('<h2>%s</h2>') % _('Uncategorised')
elif category.id == '_default_category':
pass # no title
else:
r += htmltext('<h2>%s</h2>') % category.name
workflow_section(r, category_workflows)
if len(categories) > 1:
r += htmltext('</div>')
return r.getvalue()

View File

@ -158,6 +158,24 @@ class CardDefCategory(Category):
return CardDef
class WorkflowCategory(Category):
_names = 'workflow_categories'
xml_root_node = 'workflow_category'
# declarations for serialization
XML_NODES = [
('name', 'str'),
('url_name', 'str'),
('description', 'str'),
]
@classmethod
def get_object_class(cls):
from .workflows import Workflow
return Workflow
Substitutions.register('category_name', category=_('General'), comment=_('Category Name'))
Substitutions.register('category_description', category=_('General'), comment=_('Category Description'))
Substitutions.register('category_id', category=_('General'), comment=_('Category Identifier'))

View File

@ -107,7 +107,8 @@ td.time {
}
ul.biglist li.disabled, ul.biglist li.disabled .details,
li.biglistitem.disabled, li.biglistitem.disabled .details {
li.biglistitem.disabled, li.biglistitem.disabled .details,
ul.objects-list li.unused-workflow {
color: #999;
background: #eee;
}

View File

@ -5,6 +5,7 @@
{% block appbar-actions %}
{% if not workflow.is_readonly %}
<a rel="popup" href="category">{% trans "change category" %}</a>
<a rel="popup" href="edit">{% trans "change title" %}</a>
{% endif %}
{% endblock %}

View File

@ -30,6 +30,7 @@ from quixote import get_publisher, get_request, get_response
from quixote.html import TemplateIO, htmltext
from .carddef import CardDef
from .categories import WorkflowCategory
from .conditions import Condition
from .fields import FileField
from .formdata import Evolution
@ -453,6 +454,7 @@ class Workflow(StorableObject):
backoffice_fields_formdef = None
global_actions = None
criticality_levels = None
category_id = None
def __init__(self, name=None):
StorableObject.__init__(self)
@ -482,6 +484,17 @@ class Workflow(StorableObject):
if changed:
self.store()
@property
def category(self):
return WorkflowCategory.get(self.category_id, ignore_errors=True)
@category.setter
def category(self, category):
if category:
self.category_id = category.id
elif self.category_id:
self.category_id = None
def store(self, comment=None, *args, **kwargs):
assert not self.is_readonly()
must_update = False
@ -683,6 +696,12 @@ class Workflow(StorableObject):
root.attrib['id'] = str(self.id)
ET.SubElement(root, 'name').text = force_text(self.name, charset)
if self.category:
elem = ET.SubElement(root, 'category')
elem.text = force_text(self.category.name, charset)
if include_id:
elem.attrib['category_id'] = str(self.category.id)
roles_node = ET.SubElement(root, 'roles')
if self.roles:
for role_id, role_label in sorted(self.roles.items()):
@ -750,6 +769,19 @@ class Workflow(StorableObject):
workflow.name = xml_node_text(tree.find('name'))
if tree.find('category') is not None:
category_node = tree.find('category')
if include_id and category_node.attrib.get('category_id'):
category_id = str(category_node.attrib.get('category_id'))
if WorkflowCategory.has_key(category_id):
workflow.category_id = category_id
else:
category = xml_node_text(category_node)
for c in WorkflowCategory.select():
if c.name == category:
workflow.category_id = c.id
break
if tree.find('roles') is not None:
workflow.roles = {}
for role_node in tree.findall('roles/role'):