[gen] Master-slave fields: slave values can now ajax-change when the user modifies master values.

This commit is contained in:
Gaetan Delannay 2014-03-03 18:54:21 +01:00
parent f7172be6ee
commit b8ceb66a49
15 changed files with 253 additions and 74 deletions

View file

@ -49,7 +49,8 @@ class Field:
# * showChanges If True, a variant of the field showing successive changes
# made to it is shown.
pxRender = Px('''
<x var="showChanges=showChanges|False;
<x var="showChanges=showChanges|req.get('showChanges',False);
layoutType=layoutType|req.get('layoutType');
layout=field.layouts[layoutType];
name=fieldName|field.name;
sync=field.sync[layoutType];
@ -65,8 +66,7 @@ class Field:
isMultiple=(field.multiplicity[1] == None) or \
(field.multiplicity[1] &gt; 1);
masterCss=field.slaves and ('master_%s' % name) or '';
slaveCss=field.master and ('slave_%s_%s' % \
(field.masterName, '_'.join(field.masterValue))) or '';
slaveCss=field.getSlaveCss();
tagCss=tagCss|'';
tagCss=('%s %s' % (slaveCss, tagCss)).strip();
tagId='%s_%s' % (zobj.UID(), name);
@ -170,7 +170,12 @@ class Field:
# The behaviour of this field may depend on another, "master" field
self.master = master
if master: self.master.slaves.append(self)
# When master has some value(s), there is impact on this field.
# The semantics of attribute "masterValue" below is as follows:
# - if "masterValue" is anything but a method, the field will be shown
# only when the master has this value, or one of it if multivalued;
# - if "masterValue" is a method, the value(s) of the slave field will
# be returned by this method, depending on the master value(s) that
# are given to it, as its unique parameter.
self.masterValue = gutils.initMasterValue(masterValue)
# If a field must retain attention in a particular way, set focus=True.
# It will be rendered in a special way.
@ -323,6 +328,7 @@ class Field:
if not masterData: return True
else:
master, masterValue = masterData
if masterValue and callable(masterValue): return True
reqValue = master.getRequestValue(obj.REQUEST)
# reqValue can be a list or not
if type(reqValue) not in sutils.sequenceTypes:
@ -567,6 +573,38 @@ class Field:
if self.master: return (self.master, self.masterValue)
if self.group: return self.group.getMasterData()
def getSlaveCss(self):
'''Gets the CSS class that must apply to this field in the web UI when
this field is the slave of another field.'''
if not self.master: return ''
res = 'slave*%s*' % self.masterName
if not callable(self.masterValue):
res += '*'.join(self.masterValue)
else:
res += '+'
return res
def getOnChange(self, name, zobj, layoutType):
'''When this field is a master, this method computes the call to the
Javascript function that will be called when its value changes (in
order to update slaves).'''
if not self.slaves: return ''
q = zobj.getTool().quote
# Create the dict of request values for slave fields.
rvs = {}
req = zobj.REQUEST
for slave in self.slaves:
name = slave.name
if not req.has_key(name): continue
if not req[name]: continue
rvs[name] = req[name]
if rvs:
rvs = ',%s' % sutils.getStringDict(rvs)
else:
rvs = ''
return 'updateSlaves(this,null,%s,%s%s)' % \
(q(zobj.absolute_url()), q(layoutType), rvs)
def isEmptyValue(self, value, obj=None):
'''Returns True if the p_value must be considered as an empty value.'''
return value in self.nullValues

View file

@ -33,8 +33,9 @@ class Boolean(Field):
<x var="isChecked=field.isChecked(zobj, rawValue)">
<input type="checkbox" name=":name + '_visible'" id=":name"
class=":masterCss" checked=":isChecked"
onclick=":'toggleCheckbox(%s, %s); updateSlaves(this)' % \
(q(name), q('%s_hidden' % name))"/>
onclick=":'toggleCheckbox(%s, %s); %s' % (q(name), \
q('%s_hidden' % name), field.getOnChange(name, zobj, \
layoutType))"/>
<input type="hidden" name=":name" id=":'%s_hidden' % name"
value=":isChecked and 'True' or 'False'"/>
</x>''')

View file

@ -15,7 +15,7 @@
# Appy. If not, see <http://www.gnu.org/licenses/>.
# ------------------------------------------------------------------------------
import time, os.path, mimetypes
import time, os.path, mimetypes, shutil
from DateTime import DateTime
from appy import Object
from appy.fields import Field
@ -27,6 +27,7 @@ from appy.shared import UnmarshalledFile, mimeTypesExts
WRONG_FILE_TUPLE = 'This is not the way to set a file. You can specify a ' \
'2-tuple (fileName, fileContent) or a 3-tuple (fileName, fileContent, ' \
'mimeType).'
CONVERSION_ERROR = 'An error occurred. %s'
# ------------------------------------------------------------------------------
class FileInfo:
@ -44,6 +45,12 @@ class FileInfo:
self.mimeType = None # Its MIME type
self.modified = None # The last modification date for this file.
def getFilePath(self, obj):
'''Returns the absolute file name of the file on disk that corresponds
to this FileInfo instance.'''
dbFolder, folder = obj.o.getFsFolder()
return os.path.join(dbFolder, folder, self.fsName)
def removeFile(self, dbFolder, removeEmptyFolders=False):
'''Removes the file from the filesystem.'''
try:
@ -176,6 +183,39 @@ class FileInfo:
response.write(chunk)
f.close()
def dump(self, obj, filePath=None, format=None):
'''Exports this file to disk (outside the db-controller filesystem).
The tied Appy p_obj(ect) is required. If p_filePath is specified, it
is the path name where the file will be dumped; folders mentioned in
it must exist. If not, the file will be dumped in the OS temp folder.
The absolute path name of the dumped file is returned. If an error
occurs, the method returns None. If p_format is specified,
LibreOffice will be called for converting the dumped file to the
desired format.'''
if not filePath:
filePath = '%s/file%f.%s' % (sutils.getOsTempFolder(), time.time(),
self.fsName)
# Copies the file to disk.
shutil.copyfile(self.getFilePath(obj), filePath)
if format:
# Convert the dumped file using LibreOffice
errorMessage = obj.tool.convert(filePath, format)
# Even if we have an "error" message, it could be a simple warning.
# So we will continue here and, as a subsequent check for knowing if
# an error occurred or not, we will test the existence of the
# converted file (see below).
os.remove(filePath)
# Return the name of the converted file.
baseName, ext = os.path.splitext(filePath)
if (ext == '.%s' % format):
filePath = '%s.res.%s' % (baseName, format)
else:
filePath = '%s.%s' % (baseName, format)
if not os.path.exists(filePath):
obj.log(CONVERSION_ERROR % errorMessage, type='error')
return
return filePath
# ------------------------------------------------------------------------------
class File(Field):

View file

@ -282,13 +282,11 @@ class Ref(Field):
pxEdit = Px('''
<select if="field.link"
var2="requestValue=req.get(name, []);
inRequest=req.has_key(name);
zobjects=field.getSelectableObjects(obj);
var2="zobjects=field.getSelectableObjects(obj);
uids=[o.UID() for o in \
field.getLinkedObjects(zobj).objects];
isBeingCreated=zobj.isTemporary()"
name=":name" size=":isMultiple and field.height or ''"
field.getLinkedObjects(zobj).objects]"
name=":name" id=":name" size=":isMultiple and field.height or ''"
onchange=":field.getOnChange(name, zobj, layoutType)"
multiple=":isMultiple">
<option value="" if="not isMultiple">:_('choose_a_value')</option>
<option for="ztied in zobjects" var2="uid=ztied.o.UID()"
@ -743,13 +741,31 @@ class Ref(Field):
def getSelectableObjects(self, obj):
'''This method returns the list of all objects that can be selected to
be linked as references to p_obj via p_self.'''
if not self.select:
# No select method has been defined: we must retrieve all objects
# of the referred type that the user is allowed to access.
return obj.search(self.klass)
be linked as references to p_obj via p_self. If master values are
present in the request, we use field.masterValues method instead of
self.select.'''
req = obj.request
if 'masterValues' in req:
# Convert masterValue(s) from UID(s) to real object(s).
masterValues = req['masterValues'].strip()
if not masterValues: masterValues = None
else:
masterValues = masterValues.split('*')
tool = obj.tool
if len(masterValues) == 1:
masterValues = tool.getObject(masterValues[0])
else:
masterValues = [tool.getObject(v) for v in masterValues]
res = self.masterValue(obj, masterValues)
return res
else:
return self.select(obj)
if not self.select:
# No select method has been defined: we must retrieve all
# objects of the referred type that the user is allowed to
# access.
return obj.search(self.klass)
else:
return self.select(obj)
xhtmlToText = re.compile('<.*?>', re.S)
def getReferenceLabel(self, refObject):

View file

@ -104,21 +104,19 @@ class String(Field):
pxEdit = Px('''
<x var="fmt=field.format;
isSelect=field.isSelect;
isMaster=field.slaves;
isOneLine=fmt in (0,3,4)">
<select if="isSelect"
<select if="field.isSelect"
var2="possibleValues=field.getPossibleValues(zobj, \
withTranslations=True, withBlankValue=True)"
name=":name" id=":name" class=":masterCss"
multiple=":isMultiple and 'multiple' or ''"
onchange=":isMaster and 'updateSlaves(this)' or ''"
onchange=":field.getOnChange(name, zobj, layoutType)"
size=":isMultiple and field.height or 1">
<option for="val in possibleValues" value=":val[0]"
selected=":field.isSelected(zobj, name, val[0], rawValue)"
title=":val[1]">:ztool.truncateValue(val[1],field.width)</option>
</select>
<x if="isOneLine and not isSelect">
<x if="isOneLine and not field.isSelect">
<input id=":name" name=":name" size=":field.width"
maxlength=":field.maxChars"
value=":inRequest and requestValue or value"

View file

@ -171,6 +171,9 @@ class Config:
# People having one of these roles will be able to create instances
# of classes defined in your application.
defaultCreators = ['Manager']
# The "root" classes are those that will get their menu in the user
# interface. Put their names in the list below.
rootClasses = []
# Number of translations for every page on a Translation object
translationsPerPage = 30
# Language that will be used as a basis for translating to other

View file

@ -553,9 +553,6 @@ class ZopeGenerator(Generator):
if theImport not in imports:
imports.append(theImport)
repls['imports'] = '\n'.join(imports)
# Compute root classes
repls['rootClasses'] = ','.join(["'%s'" % c.name \
for c in classesButTool if c.isRoot()])
# Compute list of class definitions
repls['appClasses'] = ','.join(['%s.%s' % (c.klass.__module__, \
c.klass.__name__) for c in classes])
@ -564,6 +561,9 @@ class ZopeGenerator(Generator):
for c in classes])
repls['allClassNames'] = ','.join(['"%s"' % c.name \
for c in classesButTool])
allShortClassNames = ['"%s":"%s"' % (c.name.split('_')[-1], c.name) \
for c in classesAll]
repls['allShortClassNames'] = ','.join(allShortClassNames)
# Compute the list of ordered attributes (forward and backward,
# inherited included) for every Appy class.
attributes = []

View file

@ -209,8 +209,12 @@ class ToolMixin(BaseMixin):
def getRootClasses(self):
'''Returns the list of root classes for this application.'''
cfg = self.getProductConfig()
return [self.getAppyClass(k) for k in cfg.rootClasses]
cfg = self.getProductConfig().appConfig
rootClasses = cfg.rootClasses
if not rootClasses:
# We consider every class as being a root class.
rootClasses = cfg.appClassNames
return [self.getAppyClass(k) for k in rootClasses]
def getSearchInfo(self, className, refInfo=None):
'''Returns, as an object:
@ -466,6 +470,10 @@ class ToolMixin(BaseMixin):
'''Gets the Appy class corresponding to the Zope class named p_name.
If p_wrapper is True, it returns the Appy wrapper. Else, it returns
the user-defined class.'''
# p_zopeName may be the name of the Zope class *or* the name of the Appy
# class (shorter, not prefixed with the underscored package path).
classes = self.getProductConfig().allShortClassNames
if zopeName in classes: zopeName = classes[zopeName]
zopeClass = self.getZopeClass(zopeName)
if wrapper: return zopeClass.wrapperClass
else: return zopeClass.wrapperClass.__bases__[-1]

View file

@ -814,6 +814,20 @@ class BaseMixin:
res.append(field)
return res
def getSlaveFieldsRequestValues(self, pageName):
'''Returns the list of slave fields having a masterValue being a
method.'''
res = {}
req = self.REQUEST
for field in self.getAllAppyTypes():
if field.page.name != pageName: continue
if field.masterValue and callable(field.masterValue):
# We have such a field
name = field.name
if req.has_key(name) and req[name]:
res[name] = req[name]
return sutils.getStringDict(res)
def getCssJs(self, fields, layoutType, res):
'''Gets, in p_res ~{'css':[s_css], 'js':[s_js]}~ the lists of
Javascript and CSS files required by Appy types p_fields when shown

View file

@ -20,10 +20,10 @@ PROJECTNAME = '<!applicationName!>'
diskFolder = os.path.dirname(<!applicationName!>.__file__)
# Applications classes, in various formats
rootClasses = [<!rootClasses!>]
appClasses = [<!appClasses!>]
appClassNames = [<!appClassNames!>]
allClassNames = [<!allClassNames!>]
allShortClassNames = {<!allShortClassNames!>}
# In the following dict, we store, for every Appy class, the ordered list of
# fields.

View file

@ -228,10 +228,13 @@ function askComputedField(hookId, objectUrl, fieldName) {
askAjaxChunk(hookId, 'GET', objectUrl, fieldName+':pxViewContent');
}
function askField(hookId, objectUrl, layoutType, showChanges){
function askField(hookId, objectUrl, layoutType, showChanges, masterValues,
requestValue){
// Sends an Ajax request for getting the content of any field.
var fieldName = hookId.split('_')[1];
var params = {'layoutType': layoutType, 'showChanges': showChanges};
if (masterValues) params['masterValues'] = masterValues.join('*');
if (requestValue) params[fieldName] = requestValue;
askAjaxChunk(hookId, 'GET', objectUrl, fieldName+':pxRender', params, null,
evalInnerScripts);
}
@ -286,14 +289,15 @@ function toggleSubTitles(tag) {
// Functions used for master/slave relationships between widgets
function getSlaveInfo(slave, infoType) {
// Returns the appropriate info about slavery, depending on p_infoType.
cssClasses = slave.className.split(' ');
var cssClasses = slave.className.split(' ');
var masterInfo = null;
// Find the CSS class containing master-related info.
for (var j=0; j < cssClasses.length; j++) {
if (cssClasses[j].indexOf('slave_') == 0) {
if (cssClasses[j].indexOf('slave*') == 0) {
// Extract, from this CSS class, master name or master values.
masterInfo = cssClasses[j].split('_');
masterInfo = cssClasses[j].split('*');
if (infoType == 'masterName') return masterInfo[1];
else return masterInfo.slice(2); // Master values
else return masterInfo.slice(2);
}
}
}
@ -337,7 +341,7 @@ function getSlaves(master) {
if (master.type == 'checkbox') {
masterName = masterName.substr(0, masterName.length-8);
}
slavePrefix = 'slave_' + masterName + '_';
slavePrefix = 'slave*' + masterName + '*';
for (var i=0; i < allSlaves.length; i++){
cssClasses = allSlaves[i].className.split(' ');
for (var j=0; j < cssClasses.length; j++) {
@ -350,37 +354,52 @@ function getSlaves(master) {
return res;
}
function updateSlaves(master, slave) {
// Given the value(s) in a master field, we must update slave's visibility.
// If p_slave is given, it updates only this slave. Else, it updates all
// slaves of p_master.
function updateSlaves(master, slave, objectUrl, layoutType, requestValues){
/* Given the value(s) in a master field, we must update slave's visibility or
value(s). If p_slave is given, it updates only this slave. Else, it updates
all slaves of p_master. */
var slaves = null;
if (slave) { slaves = [slave]; }
else { slaves = getSlaves(master); }
masterValues = getMasterValues(master);
for (var i=0; i < slaves.length; i++) {
showSlave = false;
slaveryValues = getSlaveInfo(slaves[i], 'masterValues');
for (var j=0; j < slaveryValues.length; j++) {
for (var k=0; k< masterValues.length; k++) {
if (slaveryValues[j] == masterValues[k]) showSlave = true;
if (slaveryValues[0] != '+') {
// Update slaves visibility depending on master values.
var showSlave = false;
for (var j=0; j < slaveryValues.length; j++) {
for (var k=0; k< masterValues.length; k++) {
if (slaveryValues[j] == masterValues[k]) showSlave = true;
}
}
if (showSlave) slaves[i].style.display = '';
else slaves[i].style.display = 'none';
}
else {
// Update slaves' values depending on master values.
var slaveId = slaves[i].id;
var slaveName = slaveId.split('_')[1];
var reqValue = null;
if (requestValues && (slaveName in requestValues))
reqValue = requestValues[slaveName];
askField(slaveId, objectUrl, layoutType, false, masterValues, reqValue);
}
if (showSlave) slaves[i].style.display = "";
else slaves[i].style.display = "none";
}
}
function initSlaves() {
// When the current page is loaded, we must set the correct state for all
// slave fields.
function initSlaves(objectUrl, layoutType, requestValues) {
/* When the current page is loaded, we must set the correct state for all
slave fields. p_requestValues are those from the slave fields that must
be ajax-updated. */
slaves = getElementsHavingName('table', 'slave');
i = slaves.length -1;
while (i >= 0) {
masterName = getSlaveInfo(slaves[i], 'masterName');
master = document.getElementById(masterName);
// If master is not here, we can't hide its slaves when appropriate.
if (master) updateSlaves(master, slaves[i]);
if (master) {
updateSlaves(master, slaves[i], objectUrl, layoutType, requestValues);
}
i -= 1;
}
}

View file

@ -211,7 +211,8 @@ def writeCookie(login, password, request):
# ------------------------------------------------------------------------------
def initMasterValue(v):
'''Standardizes p_v as a list of strings.'''
'''Standardizes p_v as a list of strings, excepted if p_v is a method.'''
if callable(v): return v
if not isinstance(v, bool) and not v: res = []
elif type(v) not in sutils.sequenceTypes: res = [v]
else: res = v

View file

@ -141,7 +141,9 @@ class ToolWrapper(AbstractWrapper):
</x>''')
pxPageBottom = Px('''
<script type="text/javascript">initSlaves();</script>''')
<script type="text/javascript">:'initSlaves(%s,%s,%s)' % \
(q(zobj.absolute_url()), q(layoutType), \
zobj.getSlaveFieldsRequestValues(page))</script>''')
pxPortlet = Px('''
<x var="toolUrl=tool.url;
@ -260,11 +262,11 @@ class ToolWrapper(AbstractWrapper):
# Hook for defining a PX that proposes additional links, after the links
# corresponding to top-level pages.
pxLinks = ''
pxLinks = Px('')
# Hook for defining a PX that proposes additional icons after standard
# icons in the user strip.
pxIcons = ''
pxIcons = Px('')
# Displays the content of a layouted object (a page or a field). If the
# layouted object is a page, the "layout target" (where to look for PXs)
@ -310,7 +312,7 @@ class ToolWrapper(AbstractWrapper):
</table>''', template=AbstractWrapper.pxTemplate, hook='content')
# Show on query list or grid, the field content for a given object.
pxQueryField = Px('''<x>
pxQueryField = Px('''
<!-- Title -->
<x if="field.name == 'title'"
var2="navInfo='search.%s.%s.%d.%d' % \
@ -350,8 +352,7 @@ class ToolWrapper(AbstractWrapper):
<x if="field.name != 'title'">
<x var="layoutType='cell'; innerRef=True"
if="zobj.showField(field.name, 'result')">:field.pxRender</x>
</x>
</x>''')
</x>''')
# Show query results as a list.
pxQueryResultList = Px('''

View file

@ -253,6 +253,17 @@ def keepDigits(s):
if c.isdigit(): res += c
return res
def getStringDict(d):
'''Gets the string literal corresponding to dict p_d.'''
res = []
for k, v in d.iteritems():
if type(v) not in sequenceTypes:
value = "'%s':'%s'" % (k, v)
else:
value = "'%s':%s" % (k, v)
res.append(value)
return '{%s}' % ','.join(res)
# ------------------------------------------------------------------------------
def formatNumber(n, sep=',', precision=2, tsep=' '):
'''Returns a string representation of number p_n, which can be a float

View file

@ -596,31 +596,48 @@ class XmlMarshaller:
def dumpFile(self, res, v):
'''Dumps a file into the result.'''
if not v: return
w = res.write
# p_value contains the (possibly binary) content of a file. We will
# encode it in Base64, in one or several parts.
partTag = self.getTagName('part')
res.write('<%s type="base64" number="1">' % partTag)
if hasattr(v, 'data'):
# The file is an Archetypes file.
valueType = v.data.__class__.__name__
if valueType == 'Pdata':
if v.data.__class__.__name__ == 'Pdata':
# There will be several parts.
res.write(v.data.data.encode('base64'))
w(v.data.data.encode('base64'))
# Write subsequent parts
nextPart = v.data.next
nextPartNumber = 2
nextPartNb = 2
while nextPart:
res.write('</%s>' % partTag) # Close the previous part
res.write('<%s type="base64" number="%d">' % \
(partTag, nextPartNumber))
res.write(nextPart.data.encode('base64'))
w('</%s>' % partTag) # Close the previous part
w('<%s type="base64" number="%d">' % (partTag, nextPartNb))
w(nextPart.data.encode('base64'))
nextPart = nextPart.next
nextPartNumber += 1
nextPartNb += 1
else:
res.write(v.data.encode('base64'))
w(v.data.encode('base64'))
w('</%s>' % partTag)
elif hasattr(v, 'uploadName'):
# The file is a Appy FileInfo instance. Read the file from disk.
filePath = v.getFilePath(self.instance)
f = file(filePath, 'rb')
partNb = 1
while True:
chunk = f.read(v.BYTES)
if not chunk: break
# We have one more chunk. Dump the start tag (excepted if it is
# the first chunk: the start tag has already been dumped, see
# above).
if partNb > 1:
w('<%s type="base64" number="%d">' % (partTag, partNb))
w(chunk.encode('base64'))
w('</%s>' % partTag) # Close the tag
partNb += 1
f.close()
else:
res.write(v.encode('base64'))
res.write('</%s>' % partTag)
w(v.encode('base64'))
w('</%s>' % partTag)
def dumpDict(self, res, v):
'''Dumps the XML version of dict p_v.'''
@ -704,11 +721,22 @@ class XmlMarshaller:
if fieldValue: length = len(fieldValue)
res.write(' count="%d"' % length)
if fType == 'file':
# Get the MIME type
mimeType = None
if hasattr(fieldValue, 'content_type'):
res.write(' mimeType="%s"' % fieldValue.content_type)
mimeType = fieldValue.content_type
elif hasattr(fieldValue, 'mimeType'):
mimeType = fieldValue.mimeType
if mimeType: res.write(' mimeType="%s"' % mimeType)
# Get the file name
fileName = None
if hasattr(fieldValue, 'filename'):
fileName = fieldValue.filename
elif hasattr(fieldValue, 'uploadName'):
fileName = fieldValue.uploadName
if fileName:
res.write(' name="')
self.dumpString(res, fieldValue.filename)
self.dumpString(res, fileName)
res.write('"')
res.write('>')
# Dump the field value
@ -724,6 +752,8 @@ class XmlMarshaller:
an instance at all, but another Python data structure or basic type,
p_objectType is ignored.'''
self.objectType = objectType
# The Appy object is needed to marshall its File fields.
if objectType == 'appy': self.instance = instance
# Call the XmlMarshaller constructor if it hasn't been called yet.
if not hasattr(self, 'cdata'):
XmlMarshaller.__init__(self)
@ -789,7 +819,6 @@ class XmlMarshaller:
if field.type == 'File':
fieldType = 'file'
v = field.getValue(instance)
if v: v = v._zopeFile
elif field.type == 'Ref':
fieldType = 'ref'
v = field.getValue(instance, type='zobjects')