# -*- coding: utf-8 -*- # ------------------------------------------------------------------------------ # This file is part of Appy, a framework for building applications in the Python # language. Copyright (C) 2007 Gaetan Delannay # Appy 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 3 of the License, or (at your option) any later # version. # Appy 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 # Appy. If not, see . # ------------------------------------------------------------------------------ import re, random from appy.gen.layout import Table from appy.gen.indexer import XhtmlTextExtractor from appy.fields import Field from appy.px import Px from appy.shared.data import countries from appy.shared.xml_parser import XhtmlCleaner from appy.shared.diff import HtmlDiff from appy.shared import utils as sutils # ------------------------------------------------------------------------------ digit = re.compile('[0-9]') alpha = re.compile('[a-zA-Z0-9]') letter = re.compile('[a-zA-Z]') digits = '0123456789' letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' emptyTuple = () # ------------------------------------------------------------------------------ class Selection: '''Instances of this class may be given as validator of a String, in order to tell Appy that the validator is a selection that will be computed dynamically.''' def __init__(self, methodName): # The p_methodName parameter must be the name of a method that will be # called every time Appy will need to get the list of possible values # for the related field. It must correspond to an instance method of # the class defining the related field. This method accepts no argument # and must return a list (or tuple) of pairs (lists or tuples): # (id, text), where "id" is one of the possible values for the # field, and "text" is the value as will be shown on the screen. # You can use self.translate within this method to produce an # internationalized version of "text" if needed. self.methodName = methodName def getText(self, obj, value, appyType): '''Gets the text that corresponds to p_value.''' for v, text in appyType.getPossibleValues(obj, withTranslations=True): if v == value: return text return value # ------------------------------------------------------------------------------ class String(Field): # Javascript files sometimes required by this type jsFiles = {'edit': ('ckeditor/ckeditor.js',), 'view': ('ckeditor/ckeditor.js',)} # Some predefined regular expressions that may be used as validators c = re.compile EMAIL = c('[a-zA-Z][\w\.-]*[a-zA-Z0-9]@[a-zA-Z0-9][\w\.-]*[a-zA-Z0-9]\.' \ '[a-zA-Z][a-zA-Z\.]*[a-zA-Z]') ALPHANUMERIC = c('[\w-]+') URL = c('(http|https):\/\/[a-z0-9]+([\-\.]{1}[a-z0-9]+)*(\.[a-z]{2,5})?' \ '(([0-9]{1,5})?\/.*)?') pxView = Px('''
  • ::sv
******** :value ::value
::zobj.formatText(value, format='html')
::value
::value
''') pxEdit = Px(''' :_('captcha_text', \ mapping=field.getCaptchaChallenge(req.SESSION)) ''') pxCell = Px(''' :', '.join(value)"> :field.pxView ''') pxSearch = Px('''
  

''') # Some predefined functions that may also be used as validators @staticmethod def _MODULO_97(obj, value, complement=False): '''p_value must be a string representing a number, like a bank account. this function checks that the 2 last digits are the result of computing the modulo 97 of the previous digits. Any non-digit character is ignored. If p_complement is True, it does compute the complement of modulo 97 instead of modulo 97. p_obj is not used; it will be given by the Appy validation machinery, so it must be specified as parameter. The function returns True if the check is successful.''' if not value: return True # First, remove any non-digit char v = '' for c in value: if digit.match(c): v += c # There must be at least 3 digits for performing the check if len(v) < 3: return False # Separate the real number from the check digits number = int(v[:-2]) checkNumber = int(v[-2:]) # Perform the check if complement: return (97 - (number % 97)) == checkNumber else: # The check number can't be 0. In this case, we force it to be 97. # This is the way Belgian bank account numbers work. I hope this # behaviour is general enough to be implemented here. mod97 = (number % 97) if mod97 == 0: return checkNumber == 97 else: return checkNumber == mod97 @staticmethod def MODULO_97(obj, value): return String._MODULO_97(obj, value) @staticmethod def MODULO_97_COMPLEMENT(obj, value): return String._MODULO_97(obj, value, True) BELGIAN_ENTERPRISE_NUMBER = MODULO_97_COMPLEMENT @staticmethod def BELGIAN_NISS(obj, value): '''Returns True if the NISS in p_value is valid.''' if not value: return True # Remove any non-digit from nrn niss = sutils.keepDigits(value) # NISS must be made of 11 numbers if len(niss) != 11: return False # When NRN begins with 0 or 1, it must be prefixed with number "2" for # checking the modulo 97 complement. nissForModulo = niss if niss.startswith('0') or niss.startswith('1'): nissForModulo = '2'+niss # Check modulo 97 complement return String.MODULO_97_COMPLEMENT(None, nissForModulo) @staticmethod def IBAN(obj, value): '''Checks that p_value corresponds to a valid IBAN number. IBAN stands for International Bank Account Number (ISO 13616). If the number is valid, the method returns True.''' if not value: return True # First, remove any non-digit or non-letter char v = '' for c in value: if alpha.match(c): v += c # Maximum size is 34 chars if (len(v) < 8) or (len(v) > 34): return False # 2 first chars must be a valid country code if not countries.exists(v[:2].upper()): return False # 2 next chars are a control code whose value must be between 0 and 96. try: code = int(v[2:4]) if (code < 0) or (code > 96): return False except ValueError: return False # Perform the checksum vv = v[4:] + v[:4] # Put the 4 first chars at the end. nv = '' for c in vv: # Convert each letter into a number (A=10, B=11, etc) # Ascii code for a is 65, so A=10 if we perform "minus 55" if letter.match(c): nv += str(ord(c.upper()) - 55) else: nv += c return int(nv) % 97 == 1 @staticmethod def BIC(obj, value): '''Checks that p_value corresponds to a valid BIC number. BIC stands for Bank Identifier Code (ISO 9362). If the number is valid, the method returns True.''' if not value: return True # BIC number must be 8 or 11 chars if len(value) not in (8, 11): return False # 4 first chars, representing bank name, must be letters for c in value[:4]: if not letter.match(c): return False # 2 next chars must be a valid country code if not countries.exists(value[4:6].upper()): return False # Last chars represent some location within a country (a city, a # province...). They can only be letters or figures. for c in value[6:]: if not alpha.match(c): return False return True # Possible values for "format" LINE = 0 TEXT = 1 XHTML = 2 PASSWORD = 3 CAPTCHA = 4 def __init__(self, validator=None, multiplicity=(0,1), default=None, format=LINE, show=True, page='main', group=None, layouts=None, move=0, indexed=False, searchable=False, specificReadPermission=False, specificWritePermission=False, width=None, height=None, maxChars=None, colspan=1, master=None, masterValue=None, focus=False, historized=False, mapping=None, label=None, sdefault='', scolspan=1, swidth=None, sheight=None, transform='none', styles=('p','h1','h2','h3','h4'), allowImageUpload=True, inlineEdit=False): # According to format, the widget will be different: input field, # textarea, inline editor... Note that there can be only one String # field of format CAPTCHA by page, because the captcha challenge is # stored in the session at some global key. self.format = format self.isUrl = validator == String.URL # When format is XHTML, the list of styles that the user will be able to # select in the styles dropdown is defined hereafter. self.styles = styles # When format is XHTML, do we allow the user to upload images in it ? self.allowImageUpload = allowImageUpload # When format in XHTML, can the field be inline-edited (ckeditor)? self.inlineEdit = inlineEdit # The following field has a direct impact on the text entered by the # user. It applies a transformation on it, exactly as does the CSS # "text-transform" property. Allowed values are those allowed for the # CSS property: "none" (default), "uppercase", "capitalize" or # "lowercase". self.transform = transform Field.__init__(self, validator, multiplicity, default, show, page, group, layouts, move, indexed, searchable, specificReadPermission, specificWritePermission, width, height, maxChars, colspan, master, masterValue, focus, historized, True, mapping, label, sdefault, scolspan, swidth, sheight) self.isSelect = self.isSelection() # If self.isSelect, self.sdefault must be a list of value(s). if self.isSelect and not sdefault: self.sdefault = [] # Default width, height and maxChars vary according to String format if width == None: if format == String.TEXT: self.width = 60 # This width corresponds to the standard width of an Appy page. if format == String.XHTML: self.width = None else: self.width = 30 if height == None: if format == String.TEXT: self.height = 5 elif self.isSelect: self.height = 4 else: self.height = 1 if maxChars == None: if self.isSelect: pass elif format == String.LINE: self.maxChars = 256 elif format == String.TEXT: self.maxChars = 9999 elif format == String.XHTML: self.maxChars = 99999 elif format == String.PASSWORD: self.maxChars = 20 self.filterable = self.indexed and (self.format == String.LINE) and \ not self.isSelect self.swidth = self.swidth or self.width self.sheight = self.sheight or self.height def isSelection(self): '''Does the validator of this type definition define a list of values into which the user must select one or more values?''' res = True if type(self.validator) in (list, tuple): for elem in self.validator: if not isinstance(elem, basestring): res = False break else: if not isinstance(self.validator, Selection): res = False return res def getDefaultLayouts(self): '''Returns the default layouts for this type. Default layouts can vary acccording to format, multiplicity or history.''' if self.format == String.TEXT: return {'view': 'l-f', 'edit': 'lrv-d-f'} elif self.format == String.XHTML: if self.historized: # self.historized can be a method or a boolean. If it is a # method, it means that under some condition, historization will # be enabled. So we come here also in this case. view = 'lc-f' else: view = 'l-f' return {'view': Table(view, width='100%'), 'edit': 'lrv-d-f'} elif self.isMultiValued(): return {'view': 'l-f', 'edit': 'lrv-f'} def getValue(self, obj): # Cheat if this field represents p_obj's state if self.name == 'state': return obj.State() value = Field.getValue(self, obj) if not value: if self.isMultiValued(): return emptyTuple else: return value if isinstance(value, basestring) and self.isMultiValued(): value = [value] elif isinstance(value, tuple): value = list(value) return value def store(self, obj, value): '''When the value is XHTML, we perform some cleanup.''' if (self.format == String.XHTML) and value: # When image upload is allowed, ckeditor inserts some "style" attrs # (ie for image size when images are resized). So in this case we # can't remove style-related information. try: value = XhtmlCleaner(keepStyles=False).clean(value) except XhtmlCleaner.Error, e: # Errors while parsing p_value can't prevent the user from # storing it. obj.log('Unparsable XHTML content in field "%s".' % self.name, type='warning') Field.store(self, obj, value) def getDiffValue(self, obj, value): '''Returns a version of p_value that includes the cumulative diffs between successive versions.''' res = None lastEvent = None for event in obj.workflow_history.values()[0]: if event['action'] != '_datachange_': continue if self.name not in event['changes']: continue if res == None: # We have found the first version of the field res = event['changes'][self.name][0] or '' else: # We need to produce the difference between current result and # this version. iMsg, dMsg = obj.getHistoryTexts(lastEvent) thisVersion = event['changes'][self.name][0] or '' comparator = HtmlDiff(res, thisVersion, iMsg, dMsg) res = comparator.get() lastEvent = event # Now we need to compare the result with the current version. iMsg, dMsg = obj.getHistoryTexts(lastEvent) comparator = HtmlDiff(res, value or '', iMsg, dMsg) return comparator.get() def getFormattedValue(self, obj, value, showChanges=False): if self.isEmptyValue(value): return '' res = value if self.isSelect: if isinstance(self.validator, Selection): # Value(s) come from a dynamic vocabulary val = self.validator if self.isMultiValued(): return [val.getText(obj, v, self) for v in value] else: return val.getText(obj, value, self) else: # Value(s) come from a fixed vocabulary whose texts are in # i18n files. t = obj.translate if self.isMultiValued(): res = [t('%s_list_%s' % (self.labelId, v)) for v in value] else: res = t('%s_list_%s' % (self.labelId, value)) elif (self.format == String.XHTML) and showChanges: # Compute the successive changes that occurred on p_value. res = self.getDiffValue(obj, res) # If value starts with a carriage return, add a space; else, it will # be ignored. if isinstance(res, basestring) and \ (res.startswith('\n') or res.startswith('\r\n')): res = ' ' + res return res emptyStringTuple = ('',) emptyValuesCatalogIgnored = (None, '') def getIndexValue(self, obj, forSearch=False): '''For indexing purposes, we return only strings, not unicodes.''' res = Field.getIndexValue(self, obj, forSearch) if isinstance(res, unicode): res = res.encode('utf-8') if res and forSearch and (self.format == String.XHTML): # Convert the value to simple text. extractor = XhtmlTextExtractor(raiseOnError=False) res = extractor.parse('

%s

' % res) # Ugly catalog: if I give an empty tuple as index value, it keeps the # previous value. If I give him a tuple containing an empty string, it # is ok. if isinstance(res, tuple) and not res: res = self.emptyStringTuple # Ugly catalog: if value is an empty string or None, it keeps the # previous index value. if res in self.emptyValuesCatalogIgnored: res = ' ' return res def getPossibleValues(self, obj, withTranslations=False, withBlankValue=False, className=None): '''Returns the list of possible values for this field (only for fields with self.isSelect=True). If p_withTranslations is True, instead of returning a list of string values, the result is a list of tuples (s_value, s_translation). If p_withBlankValue is True, a blank value is prepended to the list, excepted if the type is multivalued. If p_className is given, p_obj is the tool and, if we need an instance of p_className, we will need to use obj.executeQuery to find one.''' if not self.isSelect: raise Exception('This field is not a selection.') if isinstance(self.validator, Selection): # We need to call self.methodName for getting the (dynamic) values. # If methodName begins with _appy_, it is a special Appy method: # we will call it on the Mixin (=p_obj) directly. Else, it is a # user method: we will call it on the wrapper (p_obj.appy()). Some # args can be hidden into p_methodName, separated with stars, # like in this example: method1*arg1*arg2. Only string params are # supported. methodName = self.validator.methodName # Unwrap parameters if any. if methodName.find('*') != -1: elems = methodName.split('*') methodName = elems[0] args = elems[1:] else: args = () # On what object must we call the method that will produce the # values? if methodName.startswith('tool:'): obj = obj.getTool() methodName = methodName[5:] else: # We must call on p_obj. But if we have something in # p_className, p_obj is the tool and not an instance of # p_className as required. So find such an instance. if className: brains = obj.executeQuery(className, maxResults=1, brainsOnly=True) if brains: obj = brains[0].getObject() # Do we need to call the method on the object or on the wrapper? if methodName.startswith('_appy_'): exec 'res = obj.%s(*args)' % methodName else: exec 'res = obj.appy().%s(*args)' % methodName if not withTranslations: res = [v[0] for v in res] elif isinstance(res, list): res = res[:] else: # The list of (static) values is directly given in self.validator. res = [] for value in self.validator: label = '%s_list_%s' % (self.labelId, value) if withTranslations: res.append( (value, obj.translate(label)) ) else: res.append(value) if withBlankValue and not self.isMultiValued(): # Create the blank value to insert at the beginning of the list if withTranslations: blankValue = ('', obj.translate('choose_a_value')) else: blankValue = '' # Insert the blank value in the result if isinstance(res, tuple): res = (blankValue,) + res else: res.insert(0, blankValue) return res def validateValue(self, obj, value): if self.format == String.CAPTCHA: challenge = obj.REQUEST.SESSION.get('captcha', None) # Compute the challenge minus the char to remove i = challenge['number']-1 text = challenge['text'][:i] + challenge['text'][i+1:] if value != text: return obj.translate('bad_captcha') elif self.isSelect: # Check that the value is among possible values possibleValues = self.getPossibleValues(obj) if isinstance(value, basestring): error = value not in possibleValues else: error = False for v in value: if v not in possibleValues: error = True break if error: return obj.translate('bad_select_value') accents = {'é':'e','è':'e','ê':'e','ë':'e','à':'a','â':'a','ä':'a', 'ù':'u','û':'u','ü':'u','î':'i','ï':'i','ô':'o','ö':'o', 'ç':'c', 'Ç':'C', 'Ù':'U','Û':'U','Ü':'U','Î':'I','Ï':'I','Ô':'O','Ö':'O', 'É':'E','È':'E','Ê':'E','Ë':'E','À':'A','Â':'A','Ä':'A'} def applyTransform(self, value): '''Applies a transform as required by self.transform on single value p_value.''' if self.transform in ('uppercase', 'lowercase'): # For those transforms, I will remove any accent, because # (1) 'é'.upper() or 'Ê'.lower() has no effect; # (2) most of the time, if the user wants to apply such effect, it # is for ease of data manipulation, so I guess without accent. for c, n in self.accents.iteritems(): if c in value: value = value.replace(c, n) # Apply the transform if self.transform == 'lowercase': return value.lower() elif self.transform == 'uppercase': return value.upper() elif self.transform == 'capitalize': return value.capitalize() return value def getStorableValue(self, value): isString = isinstance(value, basestring) # Apply transform if required if isString and not self.isEmptyValue(value) and \ (self.transform != 'none'): value = self.applyTransform(value) # Truncate the result if longer than self.maxChars if isString and self.maxChars and (len(value) > self.maxChars): value = value[:self.maxChars] # Get a multivalued value if required. if value and self.isMultiValued() and \ (type(value) not in sutils.sequenceTypes): value = [value] return value def getIndexType(self): '''Index type varies depending on String parameters.''' # If String.isSelect, be it multivalued or not, we define a ListIndex: # this way we can use AND/OR operator. if self.isSelect: return 'ListIndex' elif self.format == String.TEXT: return 'TextIndex' elif self.format == String.XHTML: return 'XhtmlIndex' return Field.getIndexType(self) def getJs(self, layoutType, res): if self.format == String.XHTML: Field.getJs(self, layoutType, res) def getCaptchaChallenge(self, session): '''Returns a Captcha challenge in the form of a dict. At key "text", value is a string that the user will be required to re-type, but without 1 character whose position is at key "number". The challenge is stored in the p_session, for the server-side subsequent check.''' length = random.randint(5, 9) # The length of the challenge to encode number = random.randint(1, length) # The position of the char to remove text = '' # The challenge the user needs to type (minus one char) for i in range(length): j = random.randint(0, 1) chars = (j == 0) and digits or letters # Choose a char text += chars[random.randint(0,len(chars)-1)] res = {'text': text, 'number': number} session['captcha'] = res return res def generatePassword(self): '''Generates a password (we recycle here the captcha challenge generator).''' return self.getCaptchaChallenge({})['text'] def getJsInit(self, obj): '''Gets the Javascript init code for displaying a rich editor for this field (rich field only).''' # Define the attributes that will initialize the ckeditor instance for # this field. ckAttrs = {'toolbar': 'Appy', 'format_tags': '%s' % ';'.join(self.styles)} if self.width: ckAttrs['width'] = self.width if self.allowImageUpload: ckAttrs['filebrowserUploadUrl'] = '%s/upload' % obj.absolute_url() ck = [] for k, v in ckAttrs.iteritems(): if isinstance(v, int): sv = str(v) else: sv = '"%s"' % v ck.append('%s: %s' % (k, sv)) return 'CKEDITOR.replace("%s", {%s})' % (name, ', '.join(ck)) def getJsInlineInit(self, obj): '''Gets the Javascript init code for enabling inline edition of this field (rich text only).''' uid = obj.UID() return "CKEDITOR.disableAutoInline = true;\n" \ "CKEDITOR.inline('%s_%s_ck', {on: {blur: " \ "function( event ) { var data = event.editor.getData(); " \ "askAjaxChunk('%s_%s','POST','%s','page','saveField', "\ "{'fieldName':'%s', 'fieldContent': encodeURIComponent(data)}, "\ "null, evalInnerScripts);}}});"% \ (uid, self.name, uid, self.name, obj.absolute_url(), self.name) def isSelected(self, obj, vocabValue, dbValue): '''When displaying a selection box (only for fields with a validator being a list), must the _vocabValue appear as selected?''' rq = obj.REQUEST # Get the value we must compare (from request or from database) if rq.has_key(self.name): compValue = rq.get(self.name) else: compValue = dbValue # Compare the value if type(compValue) in sutils.sequenceTypes: return vocabValue in compValue return vocabValue == compValue # ------------------------------------------------------------------------------