general: introduce data card management (#35089)

This commit is contained in:
Frédéric Péters 2019-07-23 21:34:14 +02:00
parent 154fcca2b6
commit f7f14c0095
31 changed files with 1242 additions and 150 deletions

17
README
View File

@ -66,6 +66,23 @@ Some artwork from Dotclear:
# published by the Free Software Foundation; either version 2 of
# the License, or (at your option) any later version.
Image from the unDraw project:
# https://undraw.co/
#
# All images, assets and vectors published on unDraw can be used for free. You
# can use them for noncommercial and commercial purposes. You do not need to ask
# permission from or provide credit to the creator or unDraw.
#
# More precisely, unDraw grants you an nonexclusive, worldwide copyright
# license to download, copy, modify, distribute, perform, and use the assets
# provided from unDraw for free, including for commercial purposes, without
# permission from or attributing the creator or unDraw. This license does not
# include the right to compile assets, vectors or images from unDraw to
# replicate a similar or competing service, in any form or distribute the assets
# in packs. This extends to automated and non-automated ways to link, embed,
# scrape, search or download the assets included on the website without our
# consent.
Universal Feed Parser:
# __license__ = """Copyright (c) 2002-2007, Mark Pilgrim, All rights reserved.
#

View File

@ -52,6 +52,11 @@ def welco_url(request, pub):
return site_options(request, pub, 'options', 'welco_url', 'http://welco.example.net')
@pytest.fixture
def studio(request, pub):
return site_options(request, pub, 'options', 'studio', 'true')
@pytest.fixture
def emails():
with EmailsMocking() as mock:

View File

@ -45,6 +45,7 @@ from wcs.wf.jump import JumpWorkflowStatusItem
from wcs.wf.register_comment import RegisterCommenterWorkflowStatusItem
from wcs.wf.wscall import WebserviceCallStatusItem
from wcs.formdef import FormDef
from wcs.carddef import CardDef
from wcs import fields
from utilities import get_app, login, create_temporary_pub, clean_temporary_pub, HttpRequestsMocking
@ -3997,9 +3998,10 @@ def test_settings_disabled_screens(pub):
assert 'Identification' not in resp.body
assert 'Theme' not in resp.body
def test_settings_export_import(pub):
def test_settings_export_import(pub, studio):
def wipe():
FormDef.wipe()
CardDef.wipe()
Workflow.wipe()
Role.wipe()
Category.wipe()
@ -4030,6 +4032,9 @@ def test_settings_export_import(pub):
formdef = FormDef()
formdef.name = 'foo'
formdef.store()
carddef = CardDef()
carddef.name = 'bar'
carddef.store()
Category(name='baz').store()
Role(name='qux').store()
NamedDataSource(name='quux').store()
@ -4060,6 +4065,8 @@ def test_settings_export_import(pub):
filelist = zipf.namelist()
assert 'formdefs/1' not in filelist
assert 'formdefs_xml/1' in filelist
assert 'carddefs/1' not in filelist
assert 'carddefs_xml/1' in filelist
assert 'workflows/1' not in filelist
assert 'workflows_xml/1' in filelist
assert 'models/export_to_model-1.upload' not in filelist
@ -4086,6 +4093,11 @@ def test_settings_export_import(pub):
resp = resp.form.submit('submit')
assert 'Imported successfully' in resp.body
assert '1 forms' in resp.body
assert '1 cards' in resp.body
assert FormDef.count() == 1
assert FormDef.select()[0].url_name == 'foo'
assert CardDef.count() == 1
assert CardDef.select()[0].url_name == 'bar'
# check roles are found by name
wipe()
@ -4887,11 +4899,11 @@ def test_settings_permissions(pub):
assert Role.get(role2.id).allows_backoffice_access is True
# give some roles access to the forms workshop (2nd checkbox) and to the
# workflows workshop (3rd)
# workflows workshop (4th)
resp = app.get('/backoffice/settings/admin-permissions')
resp.forms[0]['permissions$c-1-1'].checked = True
resp.forms[0]['permissions$c-2-1'].checked = True
resp.forms[0]['permissions$c-2-2'].checked = True
resp.forms[0]['permissions$c-2-3'].checked = True
resp = resp.forms[0].submit()
pub.reload_cfg()
assert set(pub.cfg['admin-permissions']['forms']) == set([role2.id, role3.id])
@ -4901,7 +4913,7 @@ def test_settings_permissions(pub):
resp = app.get('/backoffice/settings/admin-permissions')
resp.forms[0]['permissions$c-1-1'].checked = False
resp.forms[0]['permissions$c-2-1'].checked = False
resp.forms[0]['permissions$c-2-2'].checked = False
resp.forms[0]['permissions$c-2-3'].checked = False
resp = resp.forms[0].submit()
pub.reload_cfg()
assert pub.cfg['admin-permissions']['forms'] == []
@ -5035,3 +5047,51 @@ def test_postgresql_settings(pub):
assert resp.form['port'].value == '5432'
resp = resp.form.submit()
assert pub.cfg['postgresql']['port'] == 5432
def test_studio_home(pub, studio):
create_superuser(pub)
app = login(get_app(pub))
resp = app.get('/backoffice/')
assert 'studio' in resp.body
resp = app.get('/backoffice/studio/')
assert '../forms/' in resp.body
assert '../cards/' in resp.body
assert '../workflows/' in resp.body
pub.cfg['admin-permissions'] = {}
for part in ('forms', 'cards', 'workflows'):
# check section link are not displayed if user has no access right
pub.cfg['admin-permissions'].update({part: ['x']}) # block access
pub.write_cfg()
if part != 'workflows':
resp = app.get('/backoffice/studio/')
assert '../%s/' % part not in resp.body
else:
resp = app.get('/backoffice/studio/', status=403) # totally closed
resp = app.get('/backoffice/')
assert 'studio' not in resp.body
def test_studio_workflows(pub, studio):
create_superuser(pub)
app = login(get_app(pub))
resp = app.get('/backoffice/workflows/')
assert '<h2>Workflows for cards</h2>' in resp.body
resp = resp.click(r'Default \(cards\)')
assert 'status/recorded/' in resp.body
assert 'status/deleted/' in resp.body
assert 'This is the default workflow,' in resp.body
def test_cards_new(pub, studio):
CardDef.wipe()
create_superuser(pub)
app = login(get_app(pub))
resp = app.get('/backoffice/cards/')
resp = resp.click('New Card')
resp.form['name'] = 'card title'
resp = resp.form.submit()
assert resp.location == 'http://example.net/backoffice/cards/1/'
resp = resp.follow()
assert '<h2>card title' in resp.body
assert CardDef.get(1).workflow_id is None
assert CardDef.get(1).disabled is False

View File

@ -22,6 +22,7 @@ from wcs.qommon.http_request import HTTPRequest
from wcs.qommon.form import PicklableUpload
from wcs.users import User
from wcs.roles import Role
from wcs.carddef import CardDef
from wcs.formdef import FormDef
from wcs.formdata import Evolution
from wcs.categories import Category
@ -2633,3 +2634,32 @@ def test_geocoding(pub):
pub.site_options.write(open(os.path.join(pub.app_dir, 'site-options.cfg'), 'w'))
resp = get_app(pub).get('/api/geocoding?q=test')
assert urlopen.call_args[0][0] == 'http://reverse.example.net/?param=value&format=json&q=test&accept-language=en'
def test_cards(pub, local_user):
Role.wipe()
role = Role(name='test')
role.store()
local_user.roles = [role.id]
local_user.store()
carddef = CardDef()
carddef.name = 'test'
carddef.fields = [fields.StringField(id='0', label='foobar', varname='foo')]
carddef.workflow_roles = {'_viewer': role.id}
carddef.store()
formdata = carddef.data_class()()
formdata.data = {'0': 'blah'}
formdata.just_created()
formdata.store()
resp = get_app(pub).get(sign_uri('/api/cards/test/list'), status=403)
resp = get_app(pub).get(sign_uri(
'/api/cards/test/list?NameID=%s' %
local_user.name_identifiers[0]))
assert len(resp.json) == 1
resp = get_app(pub).get(sign_uri(
'/api/cards/test/list?NameID=%s&full=on' %
local_user.name_identifiers[0]))
assert resp.json[0]['fields']['foo'] == 'blah'

View File

@ -41,6 +41,7 @@ from wcs.wf.wscall import WebserviceCallStatusItem
from wcs.wf.redirect_to_url import RedirectToUrlWorkflowStatusItem
from wcs.wf.register_comment import RegisterCommenterWorkflowStatusItem
from wcs.wf.resubmit import ResubmitWorkflowStatusItem
from wcs.carddef import CardDef
from wcs.categories import Category
from wcs.formdef import FormDef
from wcs.logged_errors import LoggedError
@ -102,8 +103,7 @@ def create_user(pub, is_admin=False):
return user1
def create_superuser(pub):
return create_user(pub, is_admin=True)
def create_superuser(pub): return create_user(pub, is_admin=True)
def create_environment(pub, set_receiver=True):
pub.session_manager.session_class.wipe()
@ -4034,12 +4034,12 @@ def test_inspect_page(pub, local_user):
local_user.store()
resp = login(get_app(pub)).get(formdata.get_url(backoffice=True), status=200)
assert 'Form Inspector' not in resp.body
assert 'Data Inspector' not in resp.body
resp = login(get_app(pub)).get('%sinspect' % formdata.get_url(backoffice=True), status=403)
create_user(pub, is_admin=True)
resp = login(get_app(pub)).get(formdata.get_url(backoffice=True), status=200)
resp = resp.click('Form Inspector')
resp = resp.click('Data Inspector')
assert '0' * 1000 in resp.body
assert len(resp.body) < 100000
@ -4817,3 +4817,52 @@ def test_workflow_comment_required(pub):
resp = resp.follow()
assert 'widget-with-error' not in resp.body
assert 'HELLO WORLD 2' in resp.body
def test_carddata_management(pub, studio):
user = create_user(pub)
app = login(get_app(pub))
resp = app.get('/backoffice/')
assert not 'Cards Data' in resp.body
carddef = CardDef()
carddef.name = 'foo'
carddef.fields = [
fields.StringField(id='1', label='Test', type='string', varname='foo'),
]
carddef.store()
resp = app.get('/backoffice/')
assert not 'Cards Data' in resp.body
carddef.backoffice_submission_roles = user.roles
carddef.store()
resp = app.get('/backoffice/')
assert 'Cards Data' in resp.body
carddef.backoffice_submission_roles = None
carddef.workflow_roles = {'_editor': user.roles[0]}
carddef.store()
resp = app.get('/backoffice/')
assert 'Cards Data' in resp.body
resp = app.get('/backoffice/data/')
resp = resp.click('foo')
assert 'Add' not in resp.body
carddef.backoffice_submission_roles = user.roles
carddef.store()
resp = app.get('/backoffice/data/')
resp = resp.click('foo')
assert resp.body.count('<tr') == 1 # header
assert 'Add' in resp.body
resp = resp.click('Add')
resp.form['f1'] = 'blah'
resp = resp.form.submit('submit')
assert resp.location.endswith('/backoffice/data/foo/1/')
resp = resp.follow()
assert 'Edit Card' in resp.body
assert 'Delete Card' in resp.body
resp = app.get('/backoffice/data/')
resp = resp.click('foo')
assert resp.body.count('<tr') == 2 # header + row of data

56
tests/test_carddef.py Normal file
View File

@ -0,0 +1,56 @@
import pytest
from wcs.qommon.http_request import HTTPRequest
from wcs.carddef import CardDef
from wcs.fields import StringField
from utilities import create_temporary_pub, clean_temporary_pub
def pytest_generate_tests(metafunc):
if 'pub' in metafunc.fixturenames:
metafunc.parametrize('pub', ['pickle', 'sql'], indirect=True)
@pytest.fixture
def pub(request):
pub = create_temporary_pub(sql_mode=(request.param == 'sql'))
req = HTTPRequest(None, {'SCRIPT_NAME': '/', 'SERVER_NAME': 'example.net'})
pub.set_app_dir(req)
pub.cfg['language'] = {'language': 'en'}
pub.write_cfg()
return pub
def teardown_module(module):
clean_temporary_pub()
def test_basics(pub):
carddef = CardDef()
carddef.name = 'foo'
carddef.fields = [
StringField(id='1', label='Test', type='string', varname='foo'),
]
carddef.store()
assert CardDef.get(carddef.id).name == 'foo'
carddata_class = carddef.data_class()
carddata = carddata_class()
carddata.data = {'1': 'hello world'}
carddata.just_created()
carddata.store()
assert carddata.status == 'wf-recorded'
assert carddata_class.get(carddata.id).data['1'] == 'hello world'
assert carddata_class.get(carddata.id).status == 'wf-recorded'
def test_xml_export_import(pub):
carddef = CardDef()
carddef.name = 'foo'
carddef.fields = [
StringField(id='1', label='Test', type='string', varname='foo'),
]
carddef.store()
carddef_xml = carddef.export_to_xml()
assert carddef_xml.tag == 'carddef'
carddef2 = CardDef.import_from_xml_tree(carddef_xml)
assert carddef2.name == 'foo'

View File

@ -59,18 +59,23 @@ def get_workflows(condition=lambda x: True):
class FormDefUI(object):
formdef_class = FormDef
def __init__(self, formdef):
self.formdef = formdef
def get_categories(self):
return get_categories()
def new_form_ui(self):
form = Form(enctype='multipart/form-data')
if self.formdef:
formdef = self.formdef
else:
formdef = FormDef()
form.add(StringWidget, 'name', title = _('Form Title'), required=True, size=40,
formdef = self.formdef_class()
form.add(StringWidget, 'name', title = _('Name'), required=True, size=40,
value = formdef.name)
categories = get_categories()
categories = self.get_categories()
if categories:
form.add(SingleSelectWidget, 'category_id', title = _('Category'),
value = formdef.category_id,
@ -88,10 +93,10 @@ class FormDefUI(object):
if self.formdef:
formdef = self.formdef
else:
formdef = FormDef()
formdef = self.formdef_class()
name = form.get_widget('name').parse()
formdefs_name = [x.name for x in FormDef.select(ignore_errors=True,
formdefs_name = [x.name for x in self.formdef_class.select(ignore_errors=True,
lightweight=True) if x.id != formdef.id]
if name in formdefs_name:
form.get_widget('name').set_error(_('This name is already used'))
@ -349,12 +354,22 @@ class FormDefPage(Directory):
('backoffice-submission-roles', 'backoffice_submission_roles'),
('logged-errors', 'logged_errors_dir'),]
formdef_class = FormDef
formdef_export_prefix = 'form'
formdef_ui_class = FormDefUI
delete_message = N_('You are about to irrevocably delete this form.')
delete_title = N_('Deleting Form:')
overwrite_message = N_(
'You can replace this form by uploading a file '
'or by pointing to a form URL.')
def __init__(self, component):
try:
self.formdef = FormDef.get(component)
self.formdef = self.formdef_class.get(component)
except KeyError:
raise TraversalError()
self.formdefui = FormDefUI(self.formdef)
self.formdefui = self.formdef_ui_class(self.formdef)
get_response().breadcrumb.append((component + '/', self.formdef.name))
self.fields = FieldsDirectory(self.formdef)
self.fields.html_top = self.html_top
@ -727,7 +742,7 @@ class FormDefPage(Directory):
if self.formdef.url_name == misc.simplify(self.formdef.name):
# if name and url name are in sync, keep them that way
kwargs['data-slug-sync'] = 'url_name'
form.add(StringWidget, 'name', title=_('Form Title'), required=True,
form.add(StringWidget, 'name', title=_('Name'), required=True,
size=40, value=self.formdef.name, **kwargs)
disabled_url_name = bool(self.formdef.data_class().count())
@ -745,7 +760,7 @@ class FormDefPage(Directory):
if form.is_submitted() and not form.has_errors():
new_name = form.get_widget('name').parse()
new_url_name = form.get_widget('url_name').parse()
formdefs = [x for x in FormDef.select(ignore_errors=True,
formdefs = [x for x in self.formdef_class.select(ignore_errors=True,
lightweight=True) if x.id != self.formdef.id]
if new_name in [x.name for x in formdefs]:
form.get_widget('name').set_error(_('This name is already used.'))
@ -889,7 +904,7 @@ class FormDefPage(Directory):
self.formdefui.formdef.id = None
original_name = self.formdefui.formdef.name
self.formdefui.formdef.name = self.formdefui.formdef.name + _(' (copy)')
formdef_names = [x.name for x in FormDef.select(lightweight=True)]
formdef_names = [x.name for x in self.formdef_class.select(lightweight=True)]
no = 2
while self.formdefui.formdef.name in formdef_names:
self.formdefui.formdef.name = _('%(name)s (copy %(no)d)') % {
@ -903,17 +918,16 @@ class FormDefPage(Directory):
def delete(self):
form = Form(enctype='multipart/form-data')
form.widgets.append(HtmlWidget('<p>%s</p>' % _(
'You are about to irrevocably delete this form.')))
form.widgets.append(HtmlWidget('<p>%s</p>' % _(self.delete_message)))
form.add_submit('delete', _('Delete'))
form.add_submit('cancel', _('Cancel'))
if form.get_widget('cancel').parse():
return redirect('..')
if not form.is_submitted() or form.has_errors():
get_response().breadcrumb.append(('delete', _('Delete')))
self.html_top(title = _('Delete Form'))
self.html_top(title=_(self.delete_title))
r = TemplateIO(html=True)
r += htmltext('<h2>%s %s</h2>') % (_('Deleting Form:'), self.formdef.name)
r += htmltext('<h2>%s %s</h2>') % (_(self.delete_title), self.formdef.name)
r += form.render()
return r.getvalue()
else:
@ -944,9 +958,7 @@ class FormDefPage(Directory):
self.html_top(title = _('Overwrite'))
r = TemplateIO(html=True)
r += htmltext('<h2>%s</h2>') % _('Overwrite')
r += htmltext('<p>%s</p>') % _(
'You can replace this new form by uploading a file '\
'or by pointing to a form URL.')
r += htmltext('<p>%s</p>') % _(self.overwrite_message)
r += form.render()
return r.getvalue()
@ -968,7 +980,7 @@ class FormDefPage(Directory):
error, reason = False, None
try:
new_formdef = FormDef.import_from_xml(fp, include_id=True)
new_formdef = self.formdef_class.import_from_xml(fp, include_id=True)
except FormdefImportError as e:
error = True
reason = _(e.msg)
@ -1145,7 +1157,7 @@ class FormDefPage(Directory):
response = get_response()
response.set_content_type('application/x-wcs-form')
response.set_header('content-disposition',
'attachment; filename=form-%s.wcs' % self.formdef.url_name)
'attachment; filename=%s-%s.wcs' % (self.formdef_export_prefix, self.formdef.url_name))
return '<?xml version="1.0" encoding="iso-8859-15"?>\n' + ET.tostring(x)
def archive(self):
@ -1457,7 +1469,23 @@ class FormsDirectory(AccessControlled, Directory):
categories = CategoriesDirectory()
data_sources = NamedDataSourcesDirectoryInForms()
formdef_class = FormDef
formdef_page_class = FormDefPage
formdef_ui_class = FormDefUI
import_title = N_('Import Form')
import_submit_label = N_('Import Form')
import_paragraph = N_(
'You can install a new form by uploading a file '
'or by pointing to the form URL.')
import_loading_error_message = N_('Error loading form (%s).')
import_success_message = N_(
'This form has been successfully imported. '
'Do note it is disabled by default.')
import_error_message = N_(
'Imported form contained errors and has been automatically fixed, '
'you should nevertheless check everything is ok. '
'Do note it is disabled by default.')
def html_top(self, title):
return html_top('forms', title)
@ -1487,7 +1515,7 @@ class FormsDirectory(AccessControlled, Directory):
cats = Category.select()
Category.sort_by_position(cats)
one = False
formdefs = FormDef.select(order_by='name', ignore_errors=True, lightweight=True)
formdefs = self.formdef_class.select(order_by='name', ignore_errors=True, lightweight=True)
for c in cats:
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)] \
@ -1532,7 +1560,7 @@ class FormsDirectory(AccessControlled, Directory):
get_response().breadcrumb.append( ('new', _('New')) )
if Role.count() == 0:
return template.error_page('forms', _('You first have to define roles.'))
formdefui = FormDefUI(None)
formdefui = self.formdef_ui_class(None)
form = formdefui.new_form_ui()
if form.get_widget('cancel').parse():
return redirect('.')
@ -1563,7 +1591,7 @@ class FormsDirectory(AccessControlled, Directory):
form.add(FileWidget, 'file', title=_('File'), required=False)
form.add(UrlWidget, 'url', title=_('Address'), required=False,
size=50)
form.add_submit('submit', _('Import Form'))
form.add_submit('submit', _(self.import_submit_label))
form.add_submit('cancel', _('Cancel'))
if form.get_submit() == 'cancel':
@ -1577,16 +1605,15 @@ class FormsDirectory(AccessControlled, Directory):
get_response().breadcrumb.append( ('forms/', _('Forms')) )
get_response().breadcrumb.append( ('import', _('Import')) )
self.html_top(title = _('Import Form'))
self.html_top(title=_(self.import_title))
r = TemplateIO(html=True)
r += htmltext('<h2>%s</h2>') % _('Import Form')
r += htmltext('<p>%s</p>') % _(
'You can install a new form by uploading a file '\
'or by pointing to the form URL.')
r += htmltext('<h2>%s</h2>') % _(self.import_title)
r += htmltext('<p>%s</p>') % _(self.import_paragraph)
r += form.render()
return r.getvalue()
def import_submit(self, form):
self.imported_formdef = None
if form.get_widget('file').parse():
fp = form.get_widget('file').parse().fp
elif form.get_widget('url').parse():
@ -1594,7 +1621,7 @@ class FormsDirectory(AccessControlled, Directory):
try:
fp = misc.urlopen(url)
except misc.ConnectionError as e:
form.set_error('url', _('Error loading form (%s).') % str(e))
form.set_error('url', _(self.import_loading_error_message) % str(e))
raise ValueError()
else:
form.set_error('file', _('You have to enter a file or a URL.'))
@ -1603,17 +1630,12 @@ class FormsDirectory(AccessControlled, Directory):
error, reason = False, None
try:
try:
formdef = FormDef.import_from_xml(fp)
get_session().message = ('info',
_('This form has been successfully imported. '
'Do note it is disabled by default.'))
formdef = self.formdef_class.import_from_xml(fp)
get_session().message = ('info', _(self.import_success_message))
except FormdefImportRecoverableError as e:
fp.seek(0)
formdef = FormDef.import_from_xml(fp, fix_on_error=True)
get_session().message = ('info',
_('Imported form contained errors and has been automatically fixed, '
'you should nevertheless check everything is ok. '
'Do note it is disabled by default.'))
formdef = self.formdef_class.import_from_xml(fp, fix_on_error=True)
get_session().message = ('info', _(self.import_error_message))
except FormdefImportError as e:
error = True
reason = _(e.msg)
@ -1633,6 +1655,7 @@ class FormsDirectory(AccessControlled, Directory):
form.set_error('file', msg)
raise ValueError()
self.imported_formdef = formdef
formdef.internal_identifier = None # a new one will be set in .store()
formdef.disabled = True
formdef.store()

View File

@ -19,16 +19,6 @@ from quixote.directory import Directory
class RootDirectory(Directory):
menu_items = [ # still used for access control (permissions panel)
('forms/', N_('Forms')),
('workflows/', N_('Workflows')),
('users/', N_('Users')),
('roles/', N_('Roles')),
('categories/', N_('Categories')),
('bounces/', N_('Bounces')),
('settings/', N_('Settings')),
('/', N_('WCS Form Server'))]
def _q_traverse(self, path):
url = get_request().get_path_query()
url = url.replace('/admin/', '/backoffice/', 1)

View File

@ -53,9 +53,11 @@ import qommon.ident
import qommon.template
from wcs.formdef import FormDef
from wcs.carddef import CardDef
from wcs.workflows import Workflow
from wcs.roles import Role
from wcs.backoffice.studio import StudioDirectory
from .fields import FieldDefPage, FieldsDirectory
from .data_sources import NamedDataSourcesDirectory
from .wscalls import NamedWsCallsDirectory
@ -549,11 +551,18 @@ class SettingsDirectory(QommonSettingsDirectory):
permissions = [_('Backoffice')]
permission_keys = []
for k, v in get_publisher().get_admin_root().menu_items:
if not k.endswith(str('/')):
continue
k = k.strip(str('/'))
if not k:
admin_sections = [
('forms', N_('Forms')),
('carddefs', N_('Cards')),
('workflows', N_('Workflows')),
('users', N_('Users')),
('roles', N_('Roles')),
('categories', N_('Categories')),
('bounces', N_('Bounces')),
('settings', N_('Settings')),
]
for k, v in admin_sections:
if k == 'carddef' and not StudioDirectory.is_visible():
continue
permissions.append(_(v))
permission_keys.append(k)
@ -847,6 +856,8 @@ class SettingsDirectory(QommonSettingsDirectory):
form = Form(enctype="multipart/form-data")
form.add(CheckboxWidget, 'formdefs', title = _('Forms'), value = True)
if StudioDirectory.is_visible():
form.add(CheckboxWidget, 'carddefs', title=_('Cards'), value=True)
form.add(CheckboxWidget, 'workflows', title = _('Workflows'), value = True)
if not get_cfg('sp', {}).get('idp-manage-roles'):
form.add(CheckboxWidget, 'roles', title = _('Roles'), value = True)
@ -892,6 +903,12 @@ class SettingsDirectory(QommonSettingsDirectory):
misc.indent_xml(node)
z.writestr(os.path.join('formdefs_xml', str(formdef.id)),
'<?xml version="1.0" encoding="iso-8859-15"?>\n' + ET.tostring(node))
if 'carddefs' in self.dirs:
for formdef in CardDef.select():
node = formdef.export_to_xml(include_id=True)
misc.indent_xml(node)
z.writestr(os.path.join('carddefs_xml', str(formdef.id)),
'<?xml version="1.0" encoding="iso-8859-15"?>\n' + ET.tostring(node))
if 'workflows' in self.dirs:
for workflow in Workflow.select():
node = workflow.export_to_xml(include_id=True)
@ -913,7 +930,7 @@ class SettingsDirectory(QommonSettingsDirectory):
job.store()
dirs = []
for w in ('formdefs', 'workflows', 'roles', 'categories',
for w in ('formdefs', 'carddefs', 'workflows', 'roles', 'categories',
'datasources', 'wscalls'):
if form.get_widget(w) and form.get_widget(w).parse():
dirs.append(w)
@ -991,6 +1008,8 @@ class SettingsDirectory(QommonSettingsDirectory):
r += htmltext('<ul>')
if results['formdefs']:
r += htmltext('<li>%d %s</li>') % (results['formdefs'], _('forms'))
if results['carddefs']:
r += htmltext('<li>%d %s</li>') % (results['carddefs'], _('cards'))
if results['workflows']:
r += htmltext('<li>%d %s</li>') % (results['workflows'], _('workflows'))
if results['roles']:

View File

@ -36,11 +36,13 @@ from qommon.admin.menu import command_icon
from qommon import get_logger
from wcs.workflows import *
from wcs.carddef import CardDef
from wcs.formdef import FormDef
from wcs.formdata import Evolution
from .fields import FieldDefPage, FieldsDirectory
from .data_sources import NamedDataSourcesDirectory
from .logged_errors import LoggedErrorsDirectory
from wcs.backoffice.studio import StudioDirectory
def svg(tag):
@ -1339,10 +1341,13 @@ class WorkflowPage(Directory):
]
def __init__(self, component, html_top):
try:
self.workflow = Workflow.get(component)
except KeyError:
raise errors.TraversalError()
if component == '_carddef_default':
self.workflow = CardDef.get_default_workflow()
else:
try:
self.workflow = Workflow.get(component)
except KeyError:
raise errors.TraversalError()
self.html_top = html_top
self.workflow_ui = WorkflowUI(self.workflow)
self.status_dir = WorkflowStatusDirectory(self.workflow, html_top)
@ -1804,30 +1809,51 @@ class WorkflowsDirectory(Directory):
r += htmltext('<a class="new-item" rel="popup" href="new">%s</a>') % _('New Workflow')
r += htmltext('</span>')
r += htmltext('</div>')
r += htmltext('<ul class="biglist">')
workflows_in_use = set(['_default'])
formdef_workflows = [Workflow.get_default_workflow()]
workflows_in_formdef_use = set(formdef_workflows[0].id)
for formdef in FormDef.select(lightweight=True):
workflows_in_use.add(str(formdef.workflow_id))
workflows = [Workflow.get_default_workflow()] + Workflow.select(order_by='name')
has_unused_workflows = False
for workflow in workflows:
if not str(workflow.id) in workflows_in_use:
has_unused_workflows = True
continue
r += htmltext('<li>')
r += htmltext('<strong class="label"><a href="%s/">%s</a></strong>') % (workflow.id, workflow.name)
r += htmltext('</li>')
r += htmltext('</ul>')
if has_unused_workflows:
r += htmltext('<h2>%s</h2>') % _('Unused workflows')
r += htmltext('<ul class="biglist">')
for workflow in workflows:
if str(workflow.id) in workflows_in_use:
continue
r += htmltext('<li>')
r += htmltext('<strong class="label"><a href="%s/">%s</a></strong>') % (workflow.id, workflow.name)
r += htmltext('</li>')
r += htmltext('</ul>')
workflows_in_formdef_use.add(str(formdef.workflow_id))
if StudioDirectory.is_visible():
carddef_workflows = [CardDef.get_default_workflow()]
workflows_in_carddef_use = set(carddef_workflows[0].id)
for carddef in CardDef.select(lightweight=True):
workflows_in_carddef_use.add(str(carddef.workflow_id))
form_workflow_title = _('Workflows for forms')
else:
carddef_workflows = []
workflows_in_carddef_use = set()
form_workflow_title = None
shared_workflows = []
unused_workflows = []
for workflow in Workflow.select(order_by='name'):
if (str(workflow.id) in workflows_in_formdef_use and str(workflow.id) in workflows_in_carddef_use):
shared_workflows.append(workflow)
elif str(workflow.id) in workflows_in_formdef_use:
formdef_workflows.append(workflow)
elif str(workflow.id) in workflows_in_carddef_use:
carddef_workflows.append(workflow)
else:
unused_workflows.append(workflow)
def workflow_section(r, title, workflows):
if workflows:
if title:
r += htmltext('<h2>%s</h2>') % title
r += htmltext('<ul class="biglist">')
for workflow in workflows:
r += htmltext('<li>')
r += htmltext('<strong class="label"><a href="%s/">%s</a></strong>') % (workflow.id, workflow.name)
r += htmltext('</li>')
r += htmltext('</ul>')
workflow_section(r, _('Workflows for both forms and cards'), shared_workflows)
workflow_section(r, form_workflow_title, formdef_workflows)
workflow_section(r, _('Workflows for cards'), carddef_workflows)
workflow_section(r, _('Unused workflows'), unused_workflows)
return r.getvalue()

View File

@ -35,6 +35,7 @@ from qommon.form import ComputedExpressionWidget, ConditionWidget
from wcs.categories import Category
from wcs.conditions import Condition, ValidationError
from wcs.data_sources import NamedDataSource
from wcs.carddef import CardDef
from wcs.formdef import FormDef
from wcs.roles import Role, logged_users_role
from wcs.forms.common import FormStatusPage
@ -165,10 +166,11 @@ class ApiFormdataPage(FormStatusPage):
class ApiFormPage(BackofficeFormPage):
_q_exports = [('list', 'json'), 'geojson'] # restrict to API endpoints
formdef_class = FormDef
def __init__(self, component):
try:
self.formdef = FormDef.get_by_urlname(component)
self.formdef = self.formdef_class.get_by_urlname(component)
except KeyError:
raise TraversalError()
@ -209,6 +211,10 @@ class ApiFormPage(BackofficeFormPage):
return super(ApiFormPage, self)._q_traverse(path)
class ApiCardPage(ApiFormPage):
formdef_class = CardDef
class ApiFormsDirectory(Directory):
_q_exports = ['', 'geojson']
@ -295,6 +301,11 @@ class ApiFormsDirectory(Directory):
return ApiFormPage(component)
class ApiCardsDirectory(Directory):
def _q_lookup(self, component):
return ApiCardPage(component)
class ApiFormdefDirectory(Directory):
_q_exports = ['schema', 'submit']
@ -796,8 +807,10 @@ class AutocompleteDirectory(Directory):
class ApiDirectory(Directory):
_q_exports = ['forms', 'roles', ('reverse-geocoding', 'reverse_geocoding'),
'formdefs', 'categories', 'user', 'users', 'code', 'autocomplete']
'formdefs', 'categories', 'user', 'users', 'code', 'autocomplete',
'cards']
cards = ApiCardsDirectory()
forms = ApiFormsDirectory()
formdefs = ApiFormdefsDirectory()
categories = ApiCategoriesDirectory()

264
wcs/backoffice/cards.py Normal file
View File

@ -0,0 +1,264 @@
# -*- coding: utf-8 -*-
#
# w.c.s. - web application for online forms
# Copyright (C) 2005-2019 Entr'ouvert
#
# This program 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 2 of the License, or
# (at your option) any later version.
#
# This program 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 this program; if not, see <http://www.gnu.org/licenses/>.
import time
from quixote import get_publisher, get_request, get_response, get_session, redirect
from quixote.html import TemplateIO, htmltext
from qommon import _, misc
from qommon.misc import C_
from wcs.carddef import CardDef
from wcs.roles import Role
from wcs.workflows import Workflow
from wcs.admin.forms import FormsDirectory, FormDefPage, FormDefUI, html_top
from wcs.admin.logged_errors import LoggedErrorsDirectory
class CardDefUI(FormDefUI):
formdef_class = CardDef
def get_categories(self):
return []
class CardDefPage(FormDefPage):
formdef_class = CardDef
formdef_export_prefix = 'card'
formdef_ui_class = CardDefUI
delete_message = N_('You are about to irrevocably delete this card.')
delete_title = N_('Deleting Card:')
overwrite_message = N_(
'You can replace this card by uploading a file '
'or by pointing to a form URL.')
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)
get_response().filter['sidebar'] = self.get_sidebar()
get_response().add_javascript(['jquery.js', 'widget_list.js', 'qommon.wysiwyg.js'])
r += htmltext('<div id="appbar">')
r += htmltext('<h2>%s</h2>') % self.formdef.name
r += htmltext('<span class="actions">')
r += htmltext('<a rel="popup" href="title">%s</a>') % _('change title')
r += htmltext('</span>')
r += htmltext('</div>')
if self.formdef.last_modification_time:
warning_class = ''
if (time.time() - time.mktime(self.formdef.last_modification_time)) < 600:
if get_request().user and str(get_request().user.id) != self.formdef.last_modification_user_id:
warning_class = 'recent'
r += htmltext('<p class="last-modification %s">') % warning_class
r += _('Last Modification:')
r += ' '
r += misc.localstrftime(self.formdef.last_modification_time)
r += ' '
if self.formdef.last_modification_user_id:
try:
r += _('by %s') % get_publisher().user_class.get(
self.formdef.last_modification_user_id).display_name
except KeyError:
pass
r += htmltext('</p>')
r += get_session().display_message()
def add_option_line(link, label, current_value):
return htmltext(
'<li><a rel="popup" href="%(link)s">'
'<span class="label">%(label)s</span> '
'<span class="value">%(current_value)s</span>'
'</a></li>' % {
'link': link,
'label': label,
'current_value': current_value})
r += htmltext('<div class="splitcontent-left">')
r += htmltext('<div class="bo-block">')
r += htmltext('<h3>%s</h3>') % _('Workflow')
r += htmltext('<ul class="biglist optionslist">')
if get_publisher().get_backoffice_root().is_accessible('workflows'):
# custom option line to also include a link to the workflow itself.
r += htmltext(
'<li><a rel="popup" href="%(link)s">'
'<span class="label">%(label)s</span> '
'<span class="value offset">%(current_value)s</span>'
'</a>'
'<a class="extra-link" title="%(title)s" href="../../workflows/%(workflow_id)s/">↗</a>'
'</li>' % {
'link': 'workflow',
'label': _('Workflow'),
'title': _('Open workflow page'),
'workflow_id': self.formdef.workflow.id,
'current_value': self.formdef.workflow.name or '-'})
else:
r += add_option_line('workflow', _('Workflow'),
self.formdef.workflow and self.formdef.workflow.name or '-')
if self.formdef.workflow_id:
pristine_workflow = Workflow.get(self.formdef.workflow_id)
if pristine_workflow.variables_formdef:
r += add_option_line('workflow-variables', _('Options'), '')
r += add_option_line('backoffice-submission-roles',
_('Creation Role'),
self._get_roles_label('backoffice_submission_roles'))
if self.formdef.workflow.roles:
if not self.formdef.workflow_roles:
self.formdef.workflow_roles = {}
for (wf_role_id, wf_role_label) in self.formdef.workflow.roles.items():
role_id = self.formdef.workflow_roles.get(wf_role_id)
if role_id:
try:
role = Role.get(role_id)
role_label = role.name
except KeyError:
# removed role ?
role_label = _('Unknown role (%s)') % role_id
else:
role_label = '-'
r += add_option_line('role/%s' % wf_role_id,
wf_role_label, role_label)
r += htmltext('</ul>')
r += htmltext('</div>')
r += htmltext('</div>')
r += htmltext('<div class="splitcontent-right">')
r += htmltext('<div class="bo-block">')
r += htmltext('<h3>%s</h3>') % _('Options')
r += htmltext('<ul class="biglist optionslist">')
r += add_option_line('options/geolocations',
_('Geolocation'),
self.formdef.geolocations and
C_('geolocation|Enabled') or C_('geolocation|Disabled'))
if self.formdef.digest_template:
digest_template_status = C_('template|Custom')
else:
digest_template_status = C_('template|None')
r += add_option_line('options/templates',
_('Digest Template'), digest_template_status)
r += htmltext('</ul>')
r += htmltext('</div>')
r += htmltext('</div>')
r += htmltext('<div class="bo-block clear">')
r += htmltext('<h3 class="clear">%s <span class="change">(<a href="fields/">%s</a>)</span></h3>') % (
_('Fields'), _('edit'))
r += self.get_preview()
r += htmltext('</div>')
return r.getvalue()
def get_sidebar(self):
r = TemplateIO(html=True)
r += htmltext('<ul id="sidebar-actions">')
r += htmltext('<li><a href="delete" rel="popup">%s</a></li>') % _('Delete')
r += htmltext('<li><a href="duplicate">%s</a></li>') % _('Duplicate')
r += htmltext('<li><a rel="popup" href="overwrite">%s</a></li>') % _(
'Overwrite with new import')
r += htmltext('<li><a href="export">%s</a></li>') % _('Export')
r += htmltext('</ul>')
r += LoggedErrorsDirectory.errors_block(formdef_id=self.formdef.id)
return r.getvalue()
class CardsDirectory(FormsDirectory):
_q_exports = ['', 'new', ('import', 'p_import')]
formdef_class = CardDef
formdef_page_class = CardDefPage
formdef_ui_class = CardDefUI
import_title = N_('Import Card')
import_submit_label = N_('Import Card')
import_paragraph = N_(
'You can install a new card by uploading a file '
'or by pointing to the card URL.')
import_loading_error_message = N_('Error loading card (%s).')
import_success_message = N_(
'This card has been successfully imported. ')
import_error_message = N_(
'Imported card contained errors and has been automatically fixed, '
'you should nevertheless check everything is ok. ')
def html_top(self, title):
return html_top('cards', title)
def _q_index(self):
get_response().breadcrumb.append(('cards/', _('Cards')))
self.html_top(title=_('Cards'))
r = TemplateIO(html=True)
r += htmltext('<div id="appbar">')
r += htmltext('<h2>%s</h2>') % _('Cards')
r += htmltext('<span class="actions">')
r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
r += htmltext('<a class="new-item" href="new" rel="popup">%s</a>') % _('New Card')
r += htmltext('</span>')
r += htmltext('</div>')
formdefs = self.formdef_class.select(order_by='name', ignore_errors=True, lightweight=True)
r += self.form_list(formdefs, title='')
return r.getvalue()
def new(self):
get_response().breadcrumb.append(('cards/', _('Cards')))
get_response().breadcrumb.append(('new', _('New')))
formdefui = self.formdef_ui_class(None)
form = formdefui.new_form_ui()
if form.get_widget('cancel').parse():
return redirect('.')
if form.is_submitted() and not form.has_errors():
try:
formdef = formdefui.submit_form(form)
formdef.disabled = False
formdef.store()
except ValueError:
pass
else:
return redirect(str(formdef.id) + '/')
self.html_top(title=_('New Card'))
r = TemplateIO(html=True)
r += htmltext('<h2>%s</h2>') % _('New Card')
r += form.render()
return r.getvalue()
def import_submit(self, form):
response = super(CardsDirectory, self).import_submit(form)
if self.imported_formdef:
self.imported_formdef.disabled = False
self.imported_formdef.store()
return response
def _q_lookup(self, component):
get_response().breadcrumb.append(('cards/', _('Cards')))
return self.formdef_page_class(component)

View File

@ -0,0 +1,140 @@
# w.c.s. - web application for online forms
# Copyright (C) 2005-2019 Entr'ouvert
#
# This program 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 2 of the License, or
# (at your option) any later version.
#
# This program 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 this program; if not, see <http://www.gnu.org/licenses/>.
from quixote import get_request, get_response, get_session, redirect
from quixote.html import TemplateIO, htmltext, htmlescape
from qommon import _
from qommon.backoffice.menu import html_top
from wcs.carddef import CardDef
from .management import ManagementDirectory, FormPage, FormFillPage, FormBackOfficeStatusPage
class DataManagementDirectory(ManagementDirectory):
_q_exports = ['']
def _q_traverse(self, path):
get_response().breadcrumb.append(('data/', _('Cards Data')))
return super(ManagementDirectory, self)._q_traverse(path)
def is_accessible(self, user):
if not user.can_go_in_backoffice():
return False
# only include data management if there are accessible cards
for carddef in CardDef.select(ignore_errors=True, lightweight=True, iterator=True):
for role_id in (user.roles or []):
if role_id in (carddef.backoffice_submission_roles or []):
return True
if role_id in (carddef.workflow_roles or {}).values():
return True
return False
def _q_index(self):
html_top('data_management', _('Data'))
formdefs = CardDef.select(order_by='name', ignore_errors=True, lightweight=True)
if len(formdefs) == 0:
return self.empty_site_message(_('Cards'))
r = TemplateIO(html=True)
r += get_session().display_message()
r += htmltext('<div id="appbar">')
r += htmltext('<h2>%s</h2>') % _('Cards')
r += htmltext('</div>')
user = get_request().user
if user:
cards = []
for formdef in formdefs:
if user.is_admin or formdef.is_of_concern_for_user(user):
cards.append(formdef)
r += htmltext('<ul class="biglist">')
for carddef in cards:
r += htmltext('<li><strong><a href="%s/">%s</a></strong></li>') % (carddef.url_name, carddef.name)
r += htmltext('</ul>')
return r.getvalue()
def _q_lookup(self, component):
return CardPage(component)
class CardPage(FormPage):
_q_exports = ['', 'csv', 'xls', 'ods', 'json', 'export', 'map', 'geojson', 'add']
def __init__(self, component):
try:
self.formdef = CardDef.get_by_urlname(component)
except KeyError:
raise errors.TraversalError()
self.add = CardFillPage(component)
def listing_top_actions(self):
if not self.formdef.backoffice_submission_roles:
return ''
for role in get_request().user.roles or []:
if role in self.formdef.backoffice_submission_roles:
break
else:
return ''
return htmltext('<span class="actions"><a href="./add/">%s</a></span>') % _('Add')
def get_default_filters(self, mode):
return ()
def get_default_columns(self):
field_ids = ['id', 'time']
for field in self.formdef.get_all_fields():
if hasattr(field, 'get_view_value') and field.in_listing:
field_ids.append(field.id)
return field_ids
def get_filter_from_query(self, default='waiting'):
return 'all'
def _q_lookup(self, component):
try:
filled = self.formdef.data_class().get(component)
except KeyError:
raise errors.TraversalError()
return CardBackOfficeStatusPage(self.formdef, filled)
class CardFillPage(FormFillPage):
formdef_class = CardDef
def submitted(self, form, *args):
super(CardFillPage, self).submitted(form, *args)
if get_response().get_header('location').endswith('/backoffice/submission/'):
return redirect('..')
class CardBackOfficeStatusPage(FormBackOfficeStatusPage):
form_page_class = CardFillPage
sidebar_recorded_message = N_(
'The card has been recorded on %(date)s with the number %(number)s.')
sidebar_recorded_by_agent_message = N_(
'The card has been recorded on %(date)s with the number %(number)s by %(agent)s.')
def html_top(self, title=None):
return html_top('data_management', title)
def should_fold_summary(self, mine, request_user):
return False
def should_fold_history(self):
return True

View File

@ -1034,10 +1034,20 @@ class FormPage(Directory):
if self.formdef.geolocations:
r += htmltext(' <li><a data-base-href="map" href="map%s">%s</a></li>') % (
qs, _('Plot on a Map'))
r += htmltext(' <li class="stats"><a href="stats">%s</a></li>') % _('Statistics')
if 'stats' in self._q_exports:
r += htmltext(' <li class="stats"><a href="stats">%s</a></li>') % _('Statistics')
r += htmltext('</ul>')
return r.getvalue()
def get_default_filters(self, mode):
if mode == 'listing':
# enable status filter by default
return ('status',)
if mode == 'stats':
# enable period filters by default
return ('start', 'end')
return ()
def get_filter_sidebar(self, selected_filter=None, mode='listing'):
r = TemplateIO(html=True)
@ -1046,6 +1056,7 @@ class FormPage(Directory):
FakeField('start', 'period-date', _('Start')),
FakeField('end', 'period-date', _('End')),
]
default_filters = self.get_default_filters(mode)
filter_fields = []
for field in period_fake_fields + self.get_formdef_fields():
field.enabled = False
@ -1058,12 +1069,7 @@ class FormPage(Directory):
if get_request().form:
field.enabled = 'filter-%s' % field.id in get_request().form
else:
if mode == 'listing':
# enable status filter by default
field.enabled = (field.id in ('status',))
elif mode == 'stats':
# enable period filters by default
field.enabled = (field.id in ('start', 'end'))
field.enabled = (field.id in default_filters)
if field.type in ('item', 'items'):
field.enabled = field.in_filters
@ -1223,14 +1229,18 @@ class FormPage(Directory):
return fields
def get_default_columns(self):
field_ids = ['id', 'time', 'last_update_time', 'user-label']
for field in self.formdef.get_all_fields():
if hasattr(field, 'get_view_value') and field.in_listing:
field_ids.append(field.id)
field_ids.append('status')
return field_ids
def get_fields_from_query(self, ignore_form=False):
field_ids = [x for x in get_request().form.keys()]
if not field_ids or ignore_form:
field_ids = ['id', 'time', 'last_update_time', 'user-label']
for field in self.formdef.get_all_fields():
if hasattr(field, str('get_view_value')) and field.in_listing:
field_ids.append(field.id)
field_ids.append('status')
field_ids = self.get_default_columns()
fields = []
for field in self.get_formdef_fields():
@ -1317,6 +1327,9 @@ class FormPage(Directory):
return criterias
def listing_top_actions(self):
return ''
def _q_index(self):
self.check_access()
get_logger().info('backoffice - form %s - listing' % self.formdef.name)
@ -1355,7 +1368,10 @@ class FormPage(Directory):
html_top('management', '%s - %s' % (_('Listing'), self.formdef.name))
r = TemplateIO(html=True)
r += htmltext('<div id="appbar">')
r += htmltext('<h2>%s - %s</h2>') % (self.formdef.name, _('Listing'))
r += self.listing_top_actions()
r += htmltext('</div>')
r += table
get_response().filter['sidebar'] = self.get_formdata_sidebar(qs) + \
@ -1992,6 +2008,11 @@ class FormBackOfficeStatusPage(FormStatusPage):
('user-pending-forms', 'user_pending_forms')]
form_page_class = FormFillPage
sidebar_recorded_message = N_(
'The form has been recorded on %(date)s with the number %(number)s.')
sidebar_recorded_by_agent_message = N_(
'The form has been recorded on %(date)s with the number %(number)s by %(agent)s.')
def html_top(self, title = None):
return html_top('management', title)
@ -2071,11 +2092,11 @@ class FormBackOfficeStatusPage(FormStatusPage):
formdata.submission_context['agent_id'], ignore_errors=True)
if agent_user:
r += _('The form has been recorded on %(date)s with the number %(number)s by %(agent)s.') % {
r += _(self.sidebar_recorded_by_agent_message) % {
'date': tm, 'number': formdata.get_display_id(),
'agent': agent_user.get_display_name()}
else:
r += _('The form has been recorded on %(date)s with the number %(number)s.') % {
r += _(self.sidebar_recorded_message) % {
'date': tm, 'number': formdata.get_display_id()}
r += htmltext('</p>')
try:
@ -2166,7 +2187,7 @@ class FormBackOfficeStatusPage(FormStatusPage):
(get_publisher().get_backoffice_root().is_accessible('forms') or
get_publisher().get_backoffice_root().is_accessible('workflows'))):
r += htmltext('<div class="extra-context">')
r += htmltext('<p><a href="inspect">%s</a></p>') % _('Form Inspector')
r += htmltext('<p><a href="inspect">%s</a></p>') % _('Data Inspector')
r += htmltext('</div>')
return r.getvalue()
@ -2356,15 +2377,20 @@ class FormBackOfficeStatusPage(FormStatusPage):
get_publisher().get_backoffice_root().is_accessible('workflows')):
raise errors.AccessForbiddenError()
charset = get_publisher().site_charset
get_response().breadcrumb.append(('inspect', _('Form Inspector')))
get_response().breadcrumb.append(('inspect', _('Data Inspector')))
self.html_top(self.formdef.name)
r = TemplateIO(html=True)
r += htmltext('<div id="appbar">')
r += htmltext('<h2>%s</h2>') % _('Form Inspector')
r += htmltext('<h2>%s</h2>') % _('Data Inspector')
r += htmltext('<span class="actions">')
if get_publisher().get_backoffice_root().is_accessible('forms'):
r += htmltext(' <a href="../../../forms/%s/">%s</a>') % (
self.formdef.id, _('View Form'))
if self.formdef._names == 'formdefs':
if get_publisher().get_backoffice_root().is_accessible('forms'):
r += htmltext(' <a href="../../../forms/%s/">%s</a>') % (
self.formdef.id, _('View Form'))
elif self.formdef._names == 'carddefs':
if get_publisher().get_backoffice_root().is_accessible('cards'):
r += htmltext(' <a href="../../../cards/%s/">%s</a>') % (
self.formdef.id, _('View Card'))
if get_publisher().get_backoffice_root().is_accessible('workflows'):
r += htmltext(' <a href="../../../workflows/%s/">%s</a>') % (
self.formdef.workflow.id, _('View Workflow'))

View File

@ -14,6 +14,8 @@
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <http://www.gnu.org/licenses/>.
from django.utils.translation import pgettext
from quixote import get_publisher, get_request, get_response, redirect
from quixote.html import TemplateIO, htmltext
@ -35,8 +37,11 @@ import wcs.admin.settings
import wcs.admin.users
import wcs.admin.workflows
from . import studio
from . import cards
from . import submission
from . import management
from . import data_management
class RootDirectory(BackofficeRootDirectory):
@ -49,13 +54,19 @@ class RootDirectory(BackofficeRootDirectory):
users = wcs.admin.users.UsersDirectory()
workflows = wcs.admin.workflows.WorkflowsDirectory()
management = management.ManagementDirectory()
studio = studio.StudioDirectory()
cards = cards.CardsDirectory()
data = data_management.DataManagementDirectory()
submission = submission.SubmissionDirectory()
menu_items = [
('submission/', N_('Submission')),
('management/', N_('Management')),
('forms/', N_('Forms Workshop')),
('workflows/', N_('Workflows Workshop')),
('data/', N_('Cards Data'), {'check_display_function': studio.is_visible}),
('studio/', N_('Studio'), {'check_display_function': studio.is_visible}),
('forms/', N_('Forms Workshop'), {'sub': True}),
('cards/', N_('Cards'), {'sub': True, 'check_display_function': studio.is_visible}),
('workflows/', N_('Workflows Workshop'), {'sub': True}),
('users/', N_('Users'), {'check_display_function': roles.is_visible}),
('roles/', N_('Roles'), {'check_display_function': roles.is_visible}),
('bounces/', N_('Bounces'), {'check_display_function': bounces.is_visible}),
@ -170,9 +181,12 @@ class RootDirectory(BackofficeRootDirectory):
menu_items = self.get_menu_items()
r += htmltext('<ul class="apps">')
has_studio = self.studio.is_visible()
for menu_item in menu_items:
if not 'icon' in menu_item:
continue
if menu_item['icon'] == 'studio':
continue
r += htmltext('<li class="zone-%(icon)s"><a href="%(url)s">%(label)s</a></li>') % menu_item
for menu_item in menu_items:
if 'icon' in menu_item:
@ -238,6 +252,7 @@ class RootDirectory(BackofficeRootDirectory):
backoffice_url = get_publisher().get_backoffice_url()
if not backoffice_url.endswith('/'):
backoffice_url += '/'
has_studio = self.studio.is_visible()
for item in self.menu_items:
if len(item) == 2:
item = list(item) + [{}]
@ -254,11 +269,22 @@ class RootDirectory(BackofficeRootDirectory):
label = v()
else:
label = _(v)
if has_studio:
if slug == 'forms':
label = misc.site_encode(pgettext('studio', 'Forms'))
elif slug == 'cards':
label = misc.site_encode(pgettext('studio', 'Cards'))
elif slug == 'workflows':
label = misc.site_encode(pgettext('studio', 'Workflows'))
menu_items.append({
'label': label,
'slug': slug,
'url': backoffice_url + k})
'url': backoffice_url + k,
'sub': (options.get('sub') and has_studio) or False,
})
if slug in ('home', 'forms', 'workflows', 'users', 'roles',
'categories', 'settings', 'management', 'submission'):
'categories', 'settings', 'management', 'submission',
'studio', 'cards', 'data'):
menu_items[-1]['icon'] = k.strip('/')
return menu_items

41
wcs/backoffice/studio.py Normal file
View File

@ -0,0 +1,41 @@
# w.c.s. - web application for online forms
# Copyright (C) 2005-2019 Entr'ouvert
#
# This program 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 2 of the License, or
# (at your option) any later version.
#
# This program 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 this program; if not, see <http://www.gnu.org/licenses/>.
from quixote import get_publisher
from quixote.directory import Directory
from qommon import _
from qommon.backoffice.menu import html_top
from qommon import template
class StudioDirectory(Directory):
_q_exports = ['']
def _q_index(self):
html_top('studio', _('Studio'))
return template.QommonTemplateResponse(
templates=['wcs/backoffice/studio.html'],
context={})
@classmethod
def is_visible(cls, *args):
return get_publisher().has_site_option('studio')
def is_accessible(self, user):
backoffice_root = get_publisher().get_backoffice_root()
return (backoffice_root.is_accessible('forms') or
backoffice_root.is_accessible('workflows') or
backoffice_root.is_accessible('cards'))

31
wcs/carddata.py Normal file
View File

@ -0,0 +1,31 @@
# w.c.s. - web application for online forms
# Copyright (C) 2005-2019 Entr'ouvert
#
# This program 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 2 of the License, or
# (at your option) any later version.
#
# This program 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 this program; if not, see <http://www.gnu.org/licenses/>.
from wcs.formdata import FormData
class CardData(FormData):
def get_formdef(self):
if self._formdef:
return self._formdef
from carddef import CardDef
type, id = self._names.split('-', 1)
try:
self._formdef = CardDef.get_by_urlname(id)
except KeyError:
self._formdef = None
return self._formdef
formdef = property(get_formdef)

122
wcs/carddef.py Normal file
View File

@ -0,0 +1,122 @@
# w.c.s. - web application for online forms
# Copyright (C) 2005-2019 Entr'ouvert
#
# This program 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 2 of the License, or
# (at your option) any later version.
#
# This program 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 this program; if not, see <http://www.gnu.org/licenses/>.
import new
import sys
from quixote import get_publisher
from qommon import _
from wcs.carddata import CardData
from wcs.formdef import FormDef
from wcs.workflows import Workflow
class CardDef(FormDef):
_names = 'carddefs'
data_sql_prefix = 'carddata'
pickle_module_name = 'carddef'
xml_root_node = 'carddef'
confirmation = False
def data_class(self, mode=None):
if not 'carddef' in sys.modules:
sys.modules['carddef'] = sys.modules[__name__]
if hasattr(sys.modules['carddef'], self.url_name.title()):
data_class = getattr(sys.modules['carddef'], self.url_name.title())
# only use existing data class if it has a reference to this actual
# carddef
if data_class._formdef is self:
return data_class
if (get_publisher().is_using_postgresql() and not mode == 'files') or mode == 'sql':
import sql
table_name = sql.get_formdef_table_name(self)
cls = new.classobj(self.url_name.title(), (sql.SqlCardData,),
{'_formdef': self,
'_table_name': table_name})
actions = sql.do_formdef_tables(self)
else:
cls = new.classobj(self.url_name.title(), (CardData,),
{'_names': 'card-%s' % self.internal_identifier,
'_formdef': self})
actions = []
setattr(sys.modules['carddef'], self.url_name.title(), cls)
setattr(sys.modules['wcs.carddef'], self.url_name.title(), cls)
if actions:
for action in actions:
getattr(cls, action)()
return cls
@classmethod
def get_sql_new_id(cls, id_start):
import sql
return sql.get_carddef_new_id(id_start=id_start)
@classmethod
def wipe(cls):
super(CardDef, cls).wipe()
if get_publisher().is_using_postgresql():
import sql
sql.carddef_wipe()
@classmethod
def get_default_workflow(cls):
from wcs.workflows import EditableWorkflowStatusItem, ChoiceWorkflowStatusItem
import wf.remove
workflow = Workflow(name=_('Default (cards)'))
workflow.id = '_carddef_default'
workflow.roles = {
'_viewer': _('Viewer'),
'_editor': _('Editor'),
}
status = workflow.add_status(_('Recorded'), 'recorded')
deleted_status = workflow.add_status(_('Deleted'), 'deleted')
editable = EditableWorkflowStatusItem()
editable.id = '_editable'
editable.by = ['_editor']
editable.label = _('Edit Card')
editable.status = status.id
editable.parent = status
status.items.append(editable)
action_delete = ChoiceWorkflowStatusItem()
action_delete.id = '_action_delete'
action_delete.by = ['_editor']
action_delete.label = _('Delete Card')
action_delete.status = deleted_status.id
action_delete.require_confirmation = True
action_delete.parent = status
status.items.append(action_delete)
remove = wf.remove.RemoveWorkflowStatusItem()
remove.id = '_remove'
remove.parent = deleted_status
deleted_status.items.append(remove)
return workflow
def get_url(self, backoffice=False):
# always return backoffice URL
base_url = get_publisher().get_backoffice_url() + '/data'
return '%s/%s/' % (base_url, self.url_name)
def store(self):
self.roles = self.backoffice_submission_roles
return super(CardDef, self).store()

View File

@ -302,8 +302,8 @@ class FormData(StorableObject):
# particular object, as it is required by pickle (or it will raise
# "Can't pickle %r: it's not the same object as %s.%s" if the class
# object has been changed in the course of the request).
setattr(sys.modules['formdef'], self._formdef.url_name.title(), self.__class__)
setattr(sys.modules['wcs.formdef'], self._formdef.url_name.title(), self.__class__)
setattr(sys.modules[self._formdef.pickle_module_name], self._formdef.url_name.title(), self.__class__)
setattr(sys.modules['wcs.%s' % self._formdef.pickle_module_name], self._formdef.url_name.title(), self.__class__)
has_id = (self.id is not None)
if has_id:
self.set_auto_fields()

View File

@ -71,6 +71,9 @@ class FormDef(StorableObject):
_names = 'formdefs'
_indexes = ['url_name']
_hashed_indexes = ['backoffice_submission_roles']
data_sql_prefix = 'formdata'
pickle_module_name = 'formdef'
xml_root_node = 'formdef'
name = None
description = None
@ -324,8 +327,7 @@ class FormDef(StorableObject):
if id == 0:
id = len(keys)+1
if get_publisher().is_using_postgresql():
import sql
id = sql.get_formdef_new_id(id_start=id)
id = cls.get_sql_new_id(id_start=id)
if create:
objects_dir = cls.get_objects_dir()
object_filename = os.path.join(objects_dir, fix_key(id))
@ -336,6 +338,11 @@ class FormDef(StorableObject):
os.close(fd)
return str(id)
@classmethod
def get_sql_new_id(cls, id_start):
import sql
return sql.get_formdef_new_id(id_start=id_start)
@classmethod
def wipe(cls):
super(FormDef, cls).wipe()
@ -407,7 +414,11 @@ class FormDef(StorableObject):
self._workflow = self.get_workflow_with_options(workflow)
return self._workflow
else:
return Workflow.get_default_workflow()
return self.get_default_workflow()
def get_default_workflow(self):
from wcs.workflows import Workflow
return Workflow.get_default_workflow()
def get_workflow_with_options(self, workflow):
# this needs to be kept in sync with admin/forms.ptl,
@ -843,7 +854,7 @@ class FormDef(StorableObject):
def export_to_xml(self, include_id=False):
charset = get_publisher().site_charset
root = ET.Element('formdef')
root = ET.Element(self.xml_root_node)
if include_id and self.id:
root.attrib['id'] = str(self.id)
for text_attribute in list(self.TEXT_ATTRIBUTES):
@ -1021,8 +1032,8 @@ class FormDef(StorableObject):
if not ET.iselement(tree):
tree = tree.getroot()
if tree.tag != 'formdef':
raise FormdefImportError(N_('Not a form'))
if tree.tag != cls.xml_root_node:
raise FormdefImportError(N_('Unexpected root node'))
if include_id and tree.attrib.get('id'):
formdef.id = tree.attrib.get('id')
@ -1539,8 +1550,9 @@ Substitutions.register('form_name', category=N_('Form'), comment=N_('Form Name')
def clean_drafts(publisher):
import wcs.qommon.storage as st
from .carddef import CardDef
removal_date = datetime.date.today() - datetime.timedelta(days=100)
for formdef in FormDef.select():
for formdef in FormDef.select() + CardDef.select():
for formdata in formdef.data_class().select(
[st.Equal('status', 'draft'),
st.Less('receipt_time', removal_date.timetuple())]):
@ -1567,7 +1579,8 @@ def clean_unused_files(publisher):
return obj.__class__.__name__ == 'AttachmentEvolutionPart'
def accumulate_filenames():
for formdef in FormDef.select(ignore_migration=True):
from .carddef import CardDef
for formdef in FormDef.select(ignore_migration=True) + CardDef.select(ignore_migration=True):
for option_data in (formdef.workflow_options or {}).values():
if is_upload(option_data):
yield option_data.get_filename()

View File

@ -302,7 +302,11 @@ class FormStatusPage(Directory, FormTemplateMixin):
include_authors = get_request().is_in_backoffice() or include_authors_in_form_history
return template.render(
list(self.get_formdef_template_variants(self.history_templates)),
{'formdata': self.filled, 'include_authors': include_authors})
{
'formdata': self.filled,
'include_authors': include_authors,
'view': self,
})
def check_receiver(self):
session = get_session()
@ -316,6 +320,22 @@ class FormStatusPage(Directory, FormTemplateMixin):
raise errors.AccessForbiddenError()
return user
def should_fold_summary(self, mine, request_user):
# fold the summary if the form has already been seen by the user, i.e.
# if it's user own form or if the user is present in the formdata log
# (evolution).
if mine or (request_user and self.filled.is_submitter(request_user)):
return True
elif request_user and self.filled.evolution:
for evo in self.filled.evolution:
if (str(evo.who) == str(request_user.id) or
(evo.who == '_submitter' and self.filled.is_submitter(request_user))):
return True
return False
def should_fold_history(self):
return False
def receipt(self, always_include_user=False, show_status=True, form_url='', mine=True):
request_user = user = get_request().user
if not always_include_user and get_request().user and \
@ -338,19 +358,7 @@ class FormStatusPage(Directory, FormTemplateMixin):
r = TemplateIO(html=True)
klasses = 'foldable'
# fold the summary if the form has already been seen by the user, i.e.
# if it's user own form or if the user is present in the formdata log
# (evolution).
folded = False
if mine or (request_user and self.filled.is_submitter(request_user)):
folded = True
elif request_user and self.filled.evolution:
for evo in self.filled.evolution:
if (str(evo.who) == str(request_user.id) or
(evo.who == '_submitter' and self.filled.is_submitter(request_user))):
folded = True
break
if folded:
if self.should_fold_summary(mine, request_user):
klasses += ' folded'
r += htmltext('<div class="section %s" id="summary">' % klasses)

View File

@ -198,10 +198,11 @@ class FormPage(Directory, FormTemplateMixin):
filling_templates = ['wcs/front/formdata_filling.html', 'wcs/formdata_filling.html']
validation_templates = ['wcs/front/formdata_validation.html', 'wcs/formdata_validation.html']
steps_templates = ['wcs/front/formdata_steps.html', 'wcs/formdata_steps.html']
formdef_class = FormDef
def __init__(self, component):
try:
self.formdef = FormDef.get_by_urlname(component)
self.formdef = self.formdef_class.get_by_urlname(component)
except KeyError:
raise errors.TraversalError()

View File

@ -163,7 +163,7 @@ class WcsPublisher(StubWcsPublisher):
def import_zip(self, fd):
z = zipfile.ZipFile(fd)
results = {'formdefs': 0, 'workflows': 0, 'categories': 0, 'roles': 0,
results = {'formdefs': 0, 'carddefs': 0, 'workflows': 0, 'categories': 0, 'roles': 0,
'settings': 0, 'datasources': 0, 'wscalls': 0}
def _decode_list(data):
@ -195,7 +195,7 @@ class WcsPublisher(StubWcsPublisher):
for f in z.namelist():
if '.indexes' in f:
continue
if os.path.dirname(f) in ('formdefs_xml', 'workflows_xml'):
if os.path.dirname(f) in ('formdefs_xml', 'carddefs_xml', 'workflows_xml'):
continue
path = os.path.join(self.app_dir, f)
if not os.path.exists(os.path.dirname(path)):
@ -233,15 +233,22 @@ class WcsPublisher(StubWcsPublisher):
workflow.store()
results['workflows'] += 1
# third pass, forms
# third pass, forms and cards
from wcs.formdef import FormDef
from wcs.carddef import CardDef
formdefs = []
carddefs = []
for f in z.namelist():
if os.path.dirname(f) == 'formdefs_xml' and os.path.basename(f):
formdef = FormDef.import_from_xml(z.open(f), include_id=True)
formdef.store()
formdefs.append(formdef)
results['formdefs'] += 1
if os.path.dirname(f) == 'carddefs_xml' and os.path.basename(f):
carddef = CardDef.import_from_xml(z.open(f), include_id=True)
carddef.store()
carddefs.append(carddef)
results['carddefs'] += 1
# rebuild indexes for imported objects
for k, v in results.items():
@ -253,6 +260,9 @@ class WcsPublisher(StubWcsPublisher):
if k == 'formdefs':
from formdef import FormDef
klass = FormDef
elif k == 'carddefs':
from carddef import CardDef
klass = CardDef
elif k == 'categories':
from categories import Category
klass = Category
@ -270,6 +280,10 @@ class WcsPublisher(StubWcsPublisher):
# are required.
for formdef in (formdefs or FormDef.select()):
formdef.store()
elif k == 'carddefs':
# ditto for cards
for carddef in (carddefs or CardDef.select()):
carddef.store()
z.close()
return results
@ -291,9 +305,10 @@ class WcsPublisher(StubWcsPublisher):
sql.do_tracking_code_table()
sql.do_meta_table()
from formdef import FormDef
from carddef import CardDef
conn, cur = sql.get_connection_and_cursor()
sql.drop_views(None, conn, cur)
for formdef in FormDef.select():
for formdef in FormDef.select() + CarDef.select():
sql.do_formdef_tables(formdef)
sql.migrate_global_views(conn, cur)
conn.commit()

View File

@ -1106,6 +1106,12 @@ li.zone-home a:hover { background-image: url(icons/home.large-hover.png); }
li.zone-submission a { background-image: url(icons/submission.large.png); }
li.zone-submission a:hover { background-image: url(icons/submission.large-hover.png); }
li.zone-cards a { background-image: url(icons/categories.large.png); }
li.zone-cards a:hover { background-image: url(icons/categories.large-hover.png); }
li.zone-data a { background-image: url(icons/data.large.png); }
li.zone-data a:hover { background-image: url(icons/data.large-hover.png); }
ul.apps li.zone-no-icon a {
height: 29px;
padding-top: 24px;
@ -1757,3 +1763,33 @@ ul#evolutions li.msg-in div.msg {
ul#evolutions li.msg-out div.msg {
background: #f1f8fe;
}
div#studio {
background: url(studio.svg) bottom right no-repeat;
background-size: auto 90%;
width: 100%;
height: 90%;
}
a.button.button-paragraph {
box-sizing: border-box;
display: block;
max-width: 100%;
width: 40rem;
margin-bottom: 1rem;
}
a.button.button-paragraph p {
font-weight: normal;
color: #333;
margin: 0;
line-height: 150%;
}
a.button.button-paragraph p:last-child {
padding-bottom: 5px;
}
a.button.button-paragraph:hover p {
color: white;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -28,6 +28,7 @@ from qommon.substitution import invalidate_substitution_cache
from qommon import get_cfg
import wcs.categories
import wcs.carddata
import wcs.formdata
import wcs.tracking_code
import wcs.users
@ -309,8 +310,9 @@ def get_formdef_table_name(formdef):
assert formdef.id is not None
if hasattr(formdef, 'table_name') and formdef.table_name:
return formdef.table_name
formdef.table_name = 'formdata_%s_%s' % (formdef.id,
get_name_as_sql_identifier(formdef.url_name)[:30])
formdef.table_name = '%s_%s_%s' % (
formdef.data_sql_prefix, formdef.id,
get_name_as_sql_identifier(formdef.url_name)[:30])
formdef.store()
return formdef.table_name
@ -328,6 +330,20 @@ def get_formdef_new_id(id_start):
cur.close()
return new_id
def get_carddef_new_id(id_start):
new_id = id_start
conn, cur = get_connection_and_cursor()
while True:
cur.execute('''SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name LIKE %s''', ('carddata\\_%s\\_%%' % new_id,))
if cur.fetchone()[0] == 0:
break
new_id += 1
conn.commit()
cur.close()
return new_id
def formdef_wipe():
conn, cur = get_connection_and_cursor()
cur.execute('''SELECT table_name FROM information_schema.tables
@ -338,8 +354,21 @@ def formdef_wipe():
conn.commit()
cur.close()
def carddef_wipe():
conn, cur = get_connection_and_cursor()
cur.execute('''SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name LIKE %s''', ('carddata\\_%%\\_%%',))
for table_name in [x[0] for x in cur.fetchall()]:
cur.execute('''DROP TABLE %s CASCADE''' % table_name)
conn.commit()
cur.close()
def get_formdef_view_name(formdef):
return 'wcs_view_%s_%s' % (formdef.id,
prefix = 'wcs_view'
if formdef.data_sql_prefix != 'formdata':
prefix = 'wcs_%s_view' % formdef.data_sql_prefix
return '%s_%s_%s' % (prefix, formdef.id,
get_name_as_sql_identifier(formdef.url_name)[:40])
def guard_postgres(func):
@ -719,9 +748,12 @@ def drop_views(formdef, conn, cur):
if formdef:
# remove the form view itself
view_prefix = 'wcs\\_view\\_%s\\_%%' % formdef.id
if formdef.data_sql_prefix != 'formdata':
view_prefix = 'wcs\\_%s\\_view\\_%s\\_%%' % (formdef.data_sql_prefix, formdef.id)
cur.execute('''SELECT table_name FROM information_schema.views
WHERE table_schema = 'public'
AND table_name LIKE %s''', ('wcs\\_view\\_%s\\_%%' % formdef.id ,))
AND table_name LIKE %s''', (view_prefix,))
else:
# if there's no formdef specified, remove all form views
cur.execute('''SELECT table_name FROM information_schema.views
@ -1193,7 +1225,7 @@ class SqlMixin(object):
return ids
class SqlFormData(SqlMixin, wcs.formdata.FormData):
class SqlDataMixin(SqlMixin):
_names = None # make sure StorableObject methods fail
_formdef = None
@ -1621,6 +1653,14 @@ class SqlFormData(SqlMixin, wcs.formdata.FormData):
do_tracking_code_table()
class SqlFormData(SqlDataMixin, wcs.formdata.FormData):
pass
class SqlCardData(SqlDataMixin, wcs.carddata.CardData):
pass
class SqlUser(SqlMixin, wcs.users.User):
_table_name = 'users'
_table_static_fields = [
@ -2243,7 +2283,8 @@ def set_reindex(index, value, conn=None, cur=None):
def migrate_views(conn, cur):
drop_views(None, conn, cur)
from wcs.formdef import FormDef
for formdef in FormDef.select():
from wcs.carddef import CardDef
for formdef in FormDef.select() + CardDef.select():
# make sure all formdefs have up-to-date views
do_formdef_tables(formdef, conn=conn, cur=cur, rebuild_views=True, rebuild_global_views=False)
migrate_global_views(conn, cur)

View File

@ -0,0 +1,24 @@
{% load i18n %}
{% block body %}
<div id="appbar" class="highlight">
<h2>{% trans "Studio" %}</h2>
</div>
<div id="studio">
{% if user.can_go_in_backoffice_forms %}
<a class="button button-paragraph" href="../forms/">{% trans "Forms" context "studio" %}
<p>{% trans "Forms are typically used to collect user demands." %}</p>
</a>
{% endif %}
{% if user.can_go_in_backoffice_cards %}
<a class="button button-paragraph" href="../cards/">{% trans "Cards" context "studio" %}
<p>{% trans "Cards are used to store list of structured data." %}</p>
</a>
{% endif %}
{% if user.can_go_in_backoffice_workflows %}
<a class="button button-paragraph" href="../workflows/">{% trans "Workflows" context "studio" %}
<p>{% trans "Workflows are used to add custom behaviours or actions to forms and cards." %}</p>
</a>
{% endif %}
</div>
{% endblock %}

View File

@ -1,5 +1,5 @@
{% load i18n %}
<div class="section foldable" id="evolution-log">
<div class="section foldable {% if view.should_fold_history %}folded{% endif %}" id="evolution-log">
<h2>{% trans "Log" %}</h2>
<div>
<ul id="evolutions">

View File

@ -122,6 +122,21 @@ class User(StorableObject):
pass
return False
def can_go_in_backoffice_section(self, section):
authorised_roles = set(get_cfg('admin-permissions', {}).get(section) or [])
if authorised_roles:
return bool(set(self.roles or []).intersection(authorised_roles))
return self.can_go_in_admin()
def can_go_in_backoffice_forms(self):
return self.can_go_in_backoffice_section('forms')
def can_go_in_backoffice_cards(self):
return self.can_go_in_backoffice_section('cards')
def can_go_in_backoffice_workflows(self):
return self.can_go_in_backoffice_section('workflows')
@classmethod
def get_available_roles(cls):
from roles import get_user_roles