[gen] Historization of multilingual fields.

This commit is contained in:
Gaetan Delannay 2014-09-03 18:18:27 +02:00
parent 7889277328
commit c8cf3911fa
6 changed files with 169 additions and 85 deletions

View file

@ -51,7 +51,7 @@ class Field:
# * showChanges If True, a variant of the field showing successive changes # * showChanges If True, a variant of the field showing successive changes
# made to it is shown. # made to it is shown.
pxRender = Px(''' pxRender = Px('''
<x var="showChanges=showChanges|req.get('showChanges',False); <x var="showChanges=showChanges|req.get('showChanges') == 'True';
layoutType=layoutType|req.get('layoutType'); layoutType=layoutType|req.get('layoutType');
isSearch = layoutType == 'search'; isSearch = layoutType == 'search';
layout=field.layouts[layoutType]; layout=field.layouts[layoutType];
@ -117,14 +117,24 @@ class Field:
pxRequired = Px('''<img src=":url('required.gif')"/>''') pxRequired = Px('''<img src=":url('required.gif')"/>''')
# Button for showing changes to the field. # Button for showing changes to the field.
pxChanges = Px('''<x if=":zobj.hasHistory(name)"><img class="clickable" pxChanges = Px('''
if="not showChanges" src=":url('changes')" title="_('changes_show')" <x if="zobj.hasHistory(name)">
<!-- Button for showing the field version containing changes -->
<input type="button" class="button" if="not showChanges"
var="label=_('changes_show')" value=":label"
style=":'%s; %s' % (url('changes', bg=True), \
ztool.getButtonWidth(label))"
onclick=":'askField(%s,%s,%s,null,%s)' % \ onclick=":'askField(%s,%s,%s,null,%s)' % \
(q(tagId), q(zobj.absolute_url()), q('view'), q('True'))"/><img (q(tagId), q(obj.url), q('view'), q('True'))"/>
class="clickable" if="showChanges" src=":url('changesNo')"
<!-- Button for showing the field version without changes -->
<input type="button" class="button" if="showChanges"
var="label=_('changes_hide')" value=":label"
style=":'%s; %s' % (url('changesNo', bg=True), \
ztool.getButtonWidth(label))"
onclick=":'askField(%s,%s,%s,null,%s)' % \ onclick=":'askField(%s,%s,%s,null,%s)' % \
(q(tagId), q(zobj.absolute_url(), q('view'), q('True'))" (q(tagId), q(obj.url), q('view'), q('False'))"/>
title=":_('changes_hide')"/></x>''') </x>''')
def __init__(self, validator, multiplicity, default, show, page, group, def __init__(self, validator, multiplicity, default, show, page, group,
layouts, move, indexed, searchable, specificReadPermission, layouts, move, indexed, searchable, specificReadPermission,

View file

@ -549,22 +549,25 @@ class String(Field):
if Field.isEmptyValue(self, v, obj): return if Field.isEmptyValue(self, v, obj): return
return True return True
def getDiffValue(self, obj, value): def getDiffValue(self, obj, value, language):
'''Returns a version of p_value that includes the cumulative diffs '''Returns a version of p_value that includes the cumulative diffs
between successive versions.''' between successive versions. If the field is non-multilingual, it
must be called with p_language being None. Else, p_language
identifies the language-specific part we will work on.'''
res = None res = None
lastEvent = None lastEvent = None
for event in obj.workflow_history.values()[0]: name = language and ('%s-%s' % (self.name, language)) or self.name
for event in obj.workflow_history['appy']:
if event['action'] != '_datachange_': continue if event['action'] != '_datachange_': continue
if self.name not in event['changes']: continue if name not in event['changes']: continue
if res == None: if res == None:
# We have found the first version of the field # We have found the first version of the field
res = event['changes'][self.name][0] or '' res = event['changes'][name][0] or ''
else: else:
# We need to produce the difference between current result and # We need to produce the difference between current result and
# this version. # this version.
iMsg, dMsg = obj.getHistoryTexts(lastEvent) iMsg, dMsg = obj.getHistoryTexts(lastEvent)
thisVersion = event['changes'][self.name][0] or '' thisVersion = event['changes'][name][0] or ''
comparator = HtmlDiff(res, thisVersion, iMsg, dMsg) comparator = HtmlDiff(res, thisVersion, iMsg, dMsg)
res = comparator.get() res = comparator.get()
lastEvent = event lastEvent = event
@ -573,7 +576,12 @@ class String(Field):
comparator = HtmlDiff(res, value or '', iMsg, dMsg) comparator = HtmlDiff(res, value or '', iMsg, dMsg)
return comparator.get() return comparator.get()
def getUnilingualFormattedValue(self, obj, value, showChanges=False): def getUnilingualFormattedValue(self, obj, value, showChanges=False,
language=None):
'''If no p_language is specified, this method is called by
m_getFormattedValue for getting a non-multilingual value (ie, in
most cases). Else, this method returns a formatted value for the
p_language-specific part of a multilingual value.'''
if Field.isEmptyValue(self, value): return '' if Field.isEmptyValue(self, value): return ''
res = value res = value
if self.isSelect: if self.isSelect:
@ -594,7 +602,7 @@ class String(Field):
res = t('%s_list_%s' % (self.labelId, value)) res = t('%s_list_%s' % (self.labelId, value))
elif (self.format == String.XHTML) and showChanges: elif (self.format == String.XHTML) and showChanges:
# Compute the successive changes that occurred on p_value. # Compute the successive changes that occurred on p_value.
res = self.getDiffValue(obj, res) res = self.getDiffValue(obj, res, language)
# If value starts with a carriage return, add a space; else, it will # If value starts with a carriage return, add a space; else, it will
# be ignored. # be ignored.
if isinstance(res, basestring) and \ if isinstance(res, basestring) and \
@ -609,7 +617,8 @@ class String(Field):
if not value: return value if not value: return value
res = {} res = {}
for lg in self.languages: for lg in self.languages:
res[lg]=self.getUnilingualFormattedValue(obj,value[lg],showChanges) res[lg] = self.getUnilingualFormattedValue(obj, value[lg],
showChanges, lg)
return res return res
def extractText(self, value): def extractText(self, value):
@ -773,8 +782,8 @@ class String(Field):
# Apply transform if required # Apply transform if required
if isString and not isEmpty and (self.transform != 'none'): if isString and not isEmpty and (self.transform != 'none'):
value = self.applyTransform(value) value = self.applyTransform(value)
# Clean XHTML if format is XHTML # Clean XHTML strings
if (self.format == String.XHTML) and not isEmpty: if not isEmpty and (self.format == String.XHTML):
# When image upload is allowed, ckeditor inserts some "style" attrs # When image upload is allowed, ckeditor inserts some "style" attrs
# (ie for image size when images are resized). So in this case we # (ie for image size when images are resized). So in this case we
# can't remove style-related information. # can't remove style-related information.
@ -784,6 +793,9 @@ class String(Field):
# Errors while parsing p_value can't prevent the user from # Errors while parsing p_value can't prevent the user from
# storing it. # storing it.
pass pass
# Clean TEXT strings
if not isEmpty and (self.format == String.TEXT):
value = value.replace('\r', '')
# Truncate the result if longer than self.maxChars # Truncate the result if longer than self.maxChars
if isString and self.maxChars and (len(value) > self.maxChars): if isString and self.maxChars and (len(value) > self.maxChars):
value = value[:self.maxChars] value = value[:self.maxChars]
@ -819,6 +831,11 @@ class String(Field):
rq = obj.REQUEST rq = obj.REQUEST
if rq.get('cancel') == 'True': return if rq.get('cancel') == 'True': return
requestValue = rq['fieldContent'] requestValue = rq['fieldContent']
# Remember previous value if the field is historized.
previousData = obj.rememberPreviousData([self])
# We take a copy because the data is mutable (ie, a dict).
if previousData:
previousData[self.name] = previousData[self.name].copy()
if self.isMultilingual(): if self.isMultilingual():
# We get a partial value, for one language only. # We get a partial value, for one language only.
language = rq['languageOnly'] language = rq['languageOnly']
@ -828,6 +845,12 @@ class String(Field):
else: else:
self.store(obj, self.getStorableValue(requestValue)) self.store(obj, self.getStorableValue(requestValue))
part = '' part = ''
# Update the object history when relevant
if previousData: obj.historizeData(previousData)
# Update obj's last modification date
from DateTime import DateTime
obj.modified = DateTime()
obj.reindex()
obj.log('Ajax-edited %s%s on %s.' % (self.name, part, obj.id)) obj.log('Ajax-edited %s%s on %s.' % (self.name, part, obj.id))
def getIndexType(self): def getIndexType(self):

View file

@ -152,10 +152,12 @@ class ToolMixin(BaseMixin):
'''Returns the supported languages. First one is the default.''' '''Returns the supported languages. First one is the default.'''
return self.getProductConfig(True).languages return self.getProductConfig(True).languages
def getLanguageName(self, code): def getLanguageName(self, code, lowerize=False):
'''Gets the language name (in this language) from a 2-chars language '''Gets the language name (in this language) from a 2-chars language
p_code.''' p_code.'''
return languages.get(code)[2] res = languages.get(code)[2]
if not lowerize: return res
return res.lower()
def changeLanguage(self): def changeLanguage(self):
'''Sets the language cookie with the new desired language code that is '''Sets the language cookie with the new desired language code that is

View file

@ -336,7 +336,7 @@ class BaseMixin:
value.''' value.'''
rq = self.REQUEST rq = self.REQUEST
for field in self.getAppyTypes('edit', rq.form.get('page')): for field in self.getAppyTypes('edit', rq.form.get('page')):
if not field.validable: continue if not field.validable or not field.isClientVisible(self): continue
value = field.getRequestValue(rq) value = field.getRequestValue(rq)
message = field.validate(self, value) message = field.validate(self, value)
if message: if message:
@ -592,13 +592,7 @@ class BaseMixin:
~{s_fieldName: previousFieldValue}~''' ~{s_fieldName: previousFieldValue}~'''
res = {} res = {}
for field in fields: for field in fields:
if not field.historized: continue if not field.getAttribute(self, 'historized'): continue
# appyType.historized can be a method or a boolean.
if callable(field.historized):
historized = field.callMethod(self, field.historized)
else:
historized = field.historized
if historized:
res[field.name] = field.getValue(self) res[field.name] = field.getValue(self)
return res return res
@ -622,15 +616,29 @@ class BaseMixin:
a field. The method is also called by m_historizeData below, that a field. The method is also called by m_historizeData below, that
performs "automatic" recording when a HTTP form is uploaded. Field performs "automatic" recording when a HTTP form is uploaded. Field
changes for which the previous value was empty are not recorded into changes for which the previous value was empty are not recorded into
the history if p_notForPreviouslyEmptyValues is True.''' the history if p_notForPreviouslyEmptyValues is True.
For a multilingual string field, p_changes can contain a key for
every language, of the form <field name>-<language>.'''
# Add to the p_changes dict the field labels # Add to the p_changes dict the field labels
for fieldName in changes.keys(): for name in changes.keys():
appyType = self.getAppyType(fieldName) # "name" can contain the language for multilingual fields.
if notForPreviouslyEmptyValues and \ if '-' in name:
appyType.isEmptyValue(changes[fieldName], self): fieldName, lg = name.split('-')
del changes[fieldName]
else: else:
changes[fieldName] = (changes[fieldName], appyType.labelId) fieldName = name
lg = None
field = self.getAppyType(fieldName)
if notForPreviouslyEmptyValues:
# Check if the previous field value was empty
if lg:
isEmpty = not changes[name] or not changes[name].get(lg)
else:
isEmpty = field.isEmptyValue(changes[name], self)
if isEmpty:
del changes[name]
else:
changes[name] = (changes[name], field.labelId)
# Add an event in the history # Add an event in the history
self.addHistoryEvent('_datachange_', changes=changes) self.addHistoryEvent('_datachange_', changes=changes)
@ -640,20 +648,30 @@ class BaseMixin:
historized fields, while p_self already contains the (potentially) historized fields, while p_self already contains the (potentially)
modified values.''' modified values.'''
# Remove from previousData all values that were not changed # Remove from previousData all values that were not changed
for field in previousData.keys(): for name in previousData.keys():
prev = previousData[field] field = self.getAppyType(name)
appyType = self.getAppyType(field) prev = previousData[name]
curr = appyType.getValue(self) curr = field.getValue(self)
try: try:
if (prev == curr) or ((prev == None) and (curr == '')) or \ if (prev == curr) or ((prev == None) and (curr == '')) or \
((prev == '') and (curr == None)): ((prev == '') and (curr == None)):
del previousData[field] del previousData[name]
continue
except UnicodeDecodeError, ude: except UnicodeDecodeError, ude:
# The string comparisons above may imply silent encoding-related # The string comparisons above may imply silent encoding-related
# conversions that may produce this exception. # conversions that may produce this exception.
pass continue
if (appyType.type == 'Ref') and (field in previousData): # In some cases the old value must be formatted.
previousData[field] = [r.title for r in previousData[field]] if field.type == 'Ref':
previousData[name] = [r.title for r in previousData[name]]
elif (field.type == 'String') and field.isMultilingual():
# Consider every language-specific value as a first-class value
del previousData[name]
for lg in field.languages:
lgPrev = prev and prev.get(lg) or None
lgCurr = curr and curr.get(lg) or None
if lgPrev == lgCurr: continue
previousData['%s-%s' % (name, lg)] = lgPrev
if previousData: if previousData:
self.addDataChange(previousData) self.addDataChange(previousData)
@ -1020,56 +1038,73 @@ class BaseMixin:
return 1 return 1
return 0 return 0
def findNewValue(self, field, history, stopIndex): def findNewValue(self, field, language, history, stopIndex):
'''This function tries to find a more recent version of value of p_field '''This function tries to find a more recent version of value of p_field
on p_self. It first tries to find it in history[:stopIndex+1]. If on p_self. In the case of a multilingual field, p_language is
it does not find it there, it returns the current value on p_obj.''' specified. The method first tries to find it in
history[:stopIndex+1]. If it does not find it there, it returns the
current value on p_obj.'''
i = stopIndex + 1 i = stopIndex + 1
name = language and ('%s-%s' % (field.name, language)) or field.name
while (i-1) >= 0: while (i-1) >= 0:
i -= 1 i -= 1
if history[i]['action'] != '_datachange_': continue if history[i]['action'] != '_datachange_': continue
if field.name not in history[i]['changes']: continue if name not in history[i]['changes']: continue
# We have found it! # We have found it!
return history[i]['changes'][field.name][0] or '' return history[i]['changes'][name][0] or ''
return field.getValue(self) or '' # A most recent version was not found in the history: return the current
# field value.
val = field.getValue(self)
if not language: return val or ''
return val and val.get(language) or ''
def getHistoryTexts(self, event): def getHistoryTexts(self, event):
'''Returns a tuple (insertText, deleteText) containing texts to show on, '''Returns a tuple (insertText, deleteText) containing texts to show on,
respectively, inserted and deleted chunks of text in a XHTML diff.''' respectively, inserted and deleted chunks of text in a XHTML diff.'''
tool = self.getTool() tool = self.getTool()
userName = tool.getUserName(event['actor']) mapping = {'userName': tool.getUserName(event['actor'])}
mapping = {'userName': userName.decode('utf-8')}
res = [] res = []
for type in ('insert', 'delete'): for type in ('insert', 'delete'):
msg = self.translate('history_%s' % type, mapping=mapping) msg = self.translate('history_%s' % type, mapping=mapping)
date = tool.formatDate(event['time'], withHour=True) date = tool.formatDate(event['time'], withHour=True)
msg = '%s: %s' % (date, msg) msg = '%s: %s' % (date, msg)
res.append(msg.encode('utf-8')) res.append(msg)
return res return res
def hasHistory(self, fieldName=None): def hasHistory(self, name=None):
'''Has this object an history? If p_fieldName is specified, the question '''Has this object an history? If p_name is specified, the question
becomes: has this object an history for field p_fieldName?''' becomes: has this object an history for field p_name?'''
if hasattr(self.aq_base, 'workflow_history') and self.workflow_history: if not hasattr(self.aq_base, 'workflow_history') or \
history = self.workflow_history.values()[0] not self.workflow_history: return
if not fieldName: history = self.workflow_history['appy']
if not name:
for event in history: for event in history:
if event['action'] and (event['comments'] != '_invisible_'): if event['action'] and (event['comments'] != '_invisible_'):
return True return True
else: else:
field = self.getAppyType(name)
multilingual = (field.type == 'String') and field.isMultilingual()
for event in history: for event in history:
if (event['action'] == '_datachange_') and \ if event['action'] != '_datachange_': continue
(fieldName in event['changes']) and \ # Is there a value present for this field in this data change?
event['changes'][fieldName][0]: return True if not multilingual:
if (name in event['changes']) and \
(event['changes'][name][0]):
return True
else:
# At least one language-specific value must be present
for lg in field.languages:
lgName = '%s-%s' % (field.name, lg)
if (lgName in event['changes']) and \
event['changes'][lgName][0]:
return True
def getHistory(self, startNumber=0, reverse=True, includeInvisible=False, def getHistory(self, startNumber=0, reverse=True, includeInvisible=False,
batchSize=5): batchSize=5):
'''Returns the history for this object, sorted in reverse order (most '''Returns a copy of the history for this object, sorted in p_reverse
recent change first) if p_reverse is True.''' order if specified (most recent change first), whose invisible events
# Get a copy of the history, reversed if needed, whose invisible events have been removed if p_includeInvisible is True.'''
# have been removed if needed. history = list(self.workflow_history['appy'][1:])
key = self.workflow_history.keys()[0]
history = list(self.workflow_history[key][1:])
if not includeInvisible: if not includeInvisible:
history = [e for e in history if e['comments'] != '_invisible_'] history = [e for e in history if e['comments'] != '_invisible_']
if reverse: history.reverse() if reverse: history.reverse()
@ -1088,8 +1123,15 @@ class BaseMixin:
event = history[i].copy() event = history[i].copy()
event['changes'] = {} event['changes'] = {}
for name, oldValue in history[i]['changes'].iteritems(): for name, oldValue in history[i]['changes'].iteritems():
# oldValue is a tuple (value, fieldName). # "name" can specify a language-specific part in a
field = self.getAppyType(name) # multilingual field. "oldValue" is a tuple
# (value, fieldName).
if '-' in name:
fieldName, lg = name.split('-')
else:
fieldName = name
lg = None
field = self.getAppyType(fieldName)
# Field 'name' may not exist, if the history has been # Field 'name' may not exist, if the history has been
# transferred from another site. In this case we can't show # transferred from another site. In this case we can't show
# this data change. # this data change.
@ -1099,17 +1141,20 @@ class BaseMixin:
# For rich text fields, instead of simply showing the # For rich text fields, instead of simply showing the
# previous value, we propose a diff with the next # previous value, we propose a diff with the next
# version, excepted if the previous value is empty. # version, excepted if the previous value is empty.
if field.isEmptyValue(oldValue[0]): if lg: isEmpty = not oldValue[0]
else: isEmpty = field.isEmptyValue(oldValue[0])
if isEmpty:
val = '-' val = '-'
else: else:
newValue = self.findNewValue(field, history, i-1) newValue= self.findNewValue(field, lg, history, i-1)
# Compute the diff between oldValue and newValue # Compute the diff between oldValue and newValue
iMsg, dMsg = self.getHistoryTexts(event) iMsg, dMsg = self.getHistoryTexts(event)
comparator= HtmlDiff(oldValue[0],newValue,iMsg,dMsg) comparator= HtmlDiff(oldValue[0],newValue,iMsg,dMsg)
val = comparator.get() val = comparator.get()
event['changes'][name] = (val, oldValue[1])
else: else:
val = field.getFormattedValue(self, oldValue[0]) or '-' fmt = lg and 'getUnilingualFormattedValue' or \
'getFormattedValue'
val = getattr(field, fmt)(self, oldValue[0]) or '-'
if isinstance(val, list) or isinstance(val, tuple): if isinstance(val, list) or isinstance(val, tuple):
val = '<ul>%s</ul>' % \ val = '<ul>%s</ul>' % \
''.join(['<li>%s</li>' % v for v in val]) ''.join(['<li>%s</li>' % v for v in val])

View file

@ -58,7 +58,7 @@ img { border: 0; vertical-align: middle }
.userStrip a:visited { color: #e7e7e7 } .userStrip a:visited { color: #e7e7e7 }
.breadcrumb { font-size: 11pt; padding-bottom: 6px } .breadcrumb { font-size: 11pt; padding-bottom: 6px }
.login { margin: 3px; color: black } .login { margin: 3px; color: black }
input.button { color: #666666; height: 20px; margin-bottom: 5px; input.button { color: #666666; height: 20px; margin-bottom: 5px; margin-top:2px;
cursor:pointer; font-size: 90%; padding-left: 10px; cursor:pointer; font-size: 90%; padding-left: 10px;
background-color: white; background-repeat: no-repeat; background-color: white; background-repeat: no-repeat;
background-position: 8px 25%; box-shadow: 2px 2px 2px #888888} background-position: 8px 25%; box-shadow: 2px 2px 2px #888888}
@ -75,7 +75,8 @@ input.buttonSmall { width: 100px !important; font-size: 85%; height: 18px;
.message { position: absolute; top: -40px; left: 50%; font-size: 90%; .message { position: absolute; top: -40px; left: 50%; font-size: 90%;
width: 600px; border: 1px #F0C36D solid; padding: 6px; width: 600px; border: 1px #F0C36D solid; padding: 6px;
background-color: #F9EDBE; text-align: center; margin-left: -300px; background-color: #F9EDBE; text-align: center; margin-left: -300px;
border-radius: 2px 2px 2px 2px; box-shadow: 0 2px 4px #A9A9A9 } border-radius: 2px 2px 2px 2px; box-shadow: 0 2px 4px #A9A9A9;
z-index: 10 }
.messagePopup { width: 80%; top: 0; left: 0; margin-left: 10px } .messagePopup { width: 80%; top: 0; left: 0; margin-left: 10px }
.focus { font-size: 90%; margin: 7px 0 7px 0; padding: 7px; .focus { font-size: 90%; margin: 7px 0 7px 0; padding: 7px;
background-color: #d7dee4; border-radius: 2px 2px 2px 2px; background-color: #d7dee4; border-radius: 2px 2px 2px 2px;

View file

@ -375,8 +375,11 @@ class AbstractWrapper(object):
<th align=":dleft" width="70%">:_('previous_value')</th> <th align=":dleft" width="70%">:_('previous_value')</th>
</tr> </tr>
<tr for="change in event['changes'].items()" valign="top" <tr for="change in event['changes'].items()" valign="top"
var2="field=zobj.getAppyType(change[0])"> var2="elems=change[0].split('-');
<td>::_(field.labelId)</td> field=zobj.getAppyType(elems[0]);
lg=(len(elems) == 2) and elems[1] or ''">
<td><x>::_(field.labelId)</x>
<x if="lg">:' (%s)' % ztool.getLanguageName(lg, True)</x></td>
<td>::change[1][0]</td> <td>::change[1][0]</td>
</tr> </tr>
</table> </table>