VirtualBox

source: vbox/trunk/src/VBox/ValidationKit/testmanager/core/base.py@ 61222

Last change on this file since 61222 was 61222, checked in by vboxsync, 9 years ago

testmanager/core/base.py: Fixed NULL id check on add.

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
File size: 46.8 KB
Line 
1# -*- coding: utf-8 -*-
2# $Id: base.py 61222 2016-05-27 01:48:19Z vboxsync $
3# pylint: disable=C0302
4
5"""
6Test Manager Core - Base Class(es).
7"""
8
9__copyright__ = \
10"""
11Copyright (C) 2012-2015 Oracle Corporation
12
13This file is part of VirtualBox Open Source Edition (OSE), as
14available from http://www.215389.xyz. This file is free software;
15you can redistribute it and/or modify it under the terms of the GNU
16General Public License (GPL) as published by the Free Software
17Foundation, in version 2 as it comes in the "COPYING" file of the
18VirtualBox OSE distribution. VirtualBox OSE is distributed in the
19hope that it will be useful, but WITHOUT ANY WARRANTY of any kind.
20
21The contents of this file may alternatively be used under the terms
22of the Common Development and Distribution License Version 1.0
23(CDDL) only, as it comes in the "COPYING.CDDL" file of the
24VirtualBox OSE distribution, in which case the provisions of the
25CDDL are applicable instead of those of the GPL.
26
27You may elect to license modified versions of this file under the
28terms and conditions of either the GPL or the CDDL or both.
29"""
30__version__ = "$Revision: 61222 $"
31
32
33# Standard python imports.
34import copy;
35import re;
36import socket;
37import sys;
38import uuid;
39import unittest;
40
41# Validation Kit imports.
42from common import utils;
43
44# Python 3 hacks:
45if sys.version_info[0] >= 3:
46 long = int # pylint: disable=W0622,C0103
47
48
49class TMExceptionBase(Exception):
50 """
51 For exceptions raised by any TestManager component.
52 """
53 pass;
54
55
56class TMTooManyRows(TMExceptionBase):
57 """
58 Too many rows in the result.
59 Used by ModelLogicBase decendants.
60 """
61 pass;
62
63
64class TMRowNotFound(TMExceptionBase):
65 """
66 Database row not found.
67 Used by ModelLogicBase decendants.
68 """
69 pass;
70
71
72class TMRowAlreadyExists(TMExceptionBase):
73 """
74 Database row already exists (typically raised by addEntry).
75 Used by ModelLogicBase decendants.
76 """
77 pass;
78
79
80class TMInvalidData(TMExceptionBase):
81 """
82 Data validation failed.
83 Used by ModelLogicBase decendants.
84 """
85 pass;
86
87
88class TMRowInUse(TMExceptionBase):
89 """
90 Database row is in use and cannot be deleted.
91 Used by ModelLogicBase decendants.
92 """
93 pass;
94
95
96class ModelBase(object): # pylint: disable=R0903
97 """
98 Something all classes in the logical model inherits from.
99
100 Not sure if 'logical model' is the right term here.
101 Will see if it has any purpose later on...
102 """
103
104 def __init__(self):
105 pass;
106
107
108class ModelDataBase(ModelBase): # pylint: disable=R0903
109 """
110 Something all classes in the data classes in the logical model inherits from.
111 """
112
113 ## Child classes can use this to list array attributes which should use
114 # an empty array ([]) instead of None as database NULL value.
115 kasAltArrayNull = [];
116
117 ## validate
118 ## @{
119 ksValidateFor_Add = 'add';
120 ksValidateFor_AddForeignId = 'add-foreign-id';
121 ksValidateFor_Edit = 'edit';
122 ksValidateFor_Other = 'other';
123 ## @}
124
125
126 def __init__(self):
127 ModelBase.__init__(self);
128
129
130 #
131 # Standard methods implemented by combining python magic and hungarian prefixes.
132 #
133
134 def getDataAttributes(self):
135 """
136 Returns a list of data attributes.
137 """
138 asRet = [];
139 asAttrs = dir(self);
140 for sAttr in asAttrs:
141 if sAttr[0] == '_' or sAttr[0] == 'k':
142 continue;
143 oValue = getattr(self, sAttr);
144 if callable(oValue):
145 continue;
146 asRet.append(sAttr);
147 return asRet;
148
149 def initFromOther(self, oOther):
150 """
151 Initialize this object with the values from another instance (child
152 class instance is accepted).
153
154 This serves as a kind of copy constructor.
155
156 Returns self. May raise exception if the type of other object differs
157 or is damaged.
158 """
159 for sAttr in self.getDataAttributes():
160 setattr(self, sAttr, getattr(oOther, sAttr));
161 return self;
162
163 @staticmethod
164 def getHungarianPrefix(sName):
165 """
166 Returns the hungarian prefix of the given name.
167 """
168 for i, _ in enumerate(sName):
169 if sName[i] not in ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
170 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']:
171 assert re.search('^[A-Z][a-zA-Z0-9]*$', sName[i:]) is not None;
172 return sName[:i];
173 return sName;
174
175 def getAttributeParamNullValues(self, sAttr):
176 """
177 Returns a list of parameter NULL values, with the preferred one being
178 the first element.
179
180 Child classes can override this to handle one or more attributes specially.
181 """
182 sPrefix = self.getHungarianPrefix(sAttr);
183 if sPrefix in ['id', 'uid', 'i', 'off', 'pct']:
184 return [-1, '', '-1',];
185 elif sPrefix in ['l', 'c',]:
186 return [long(-1), '', '-1',];
187 elif sPrefix == 'f':
188 return ['',];
189 elif sPrefix in ['enm', 'ip', 's', 'ts', 'uuid']:
190 return ['',];
191 elif sPrefix in ['ai', 'aid', 'al', 'as']:
192 return [[], '', None]; ## @todo ??
193 elif sPrefix == 'bm':
194 return ['', [],]; ## @todo bitmaps.
195 raise TMExceptionBase('Unable to classify "%s" (prefix %s)' % (sAttr, sPrefix));
196
197 def isAttributeNull(self, sAttr, oValue):
198 """
199 Checks if the specified attribute value indicates NULL.
200 Return True/False.
201
202 Note! This isn't entirely kosher actually.
203 """
204 if oValue is None:
205 return True;
206 aoNilValues = self.getAttributeParamNullValues(sAttr);
207 return oValue in aoNilValues;
208
209 def _convertAttributeFromParamNull(self, sAttr, oValue):
210 """
211 Converts an attribute from parameter NULL to database NULL value.
212 Returns the new attribute value.
213 """
214 aoNullValues = self.getAttributeParamNullValues(sAttr);
215 if oValue in aoNullValues:
216 oValue = None if sAttr not in self.kasAltArrayNull else [];
217 #
218 # Perform deep conversion on ModelDataBase object and lists of them.
219 #
220 elif isinstance(oValue, list) and len(oValue) > 0 and isinstance(oValue[0], ModelDataBase):
221 oValue = copy.copy(oValue);
222 for i, _ in enumerate(oValue):
223 assert isinstance(oValue[i], ModelDataBase);
224 oValue[i] = copy.copy(oValue[i]);
225 oValue[i].convertFromParamNull();
226
227 elif isinstance(oValue, ModelDataBase):
228 oValue = copy.copy(oValue);
229 oValue.convertFromParamNull();
230
231 return oValue;
232
233 def convertFromParamNull(self):
234 """
235 Converts from parameter NULL values to database NULL values (None).
236 Returns self.
237 """
238 for sAttr in self.getDataAttributes():
239 oValue = getattr(self, sAttr);
240 oNewValue = self._convertAttributeFromParamNull(sAttr, oValue);
241 if oValue != oNewValue:
242 setattr(self, sAttr, oNewValue);
243 return self;
244
245 def _convertAttributeToParamNull(self, sAttr, oValue):
246 """
247 Converts an attribute from database NULL to a sepcial value we can pass
248 thru parameter list.
249 Returns the new attribute value.
250 """
251 if oValue is None:
252 oValue = self.getAttributeParamNullValues(sAttr)[0];
253 #
254 # Perform deep conversion on ModelDataBase object and lists of them.
255 #
256 elif isinstance(oValue, list) and len(oValue) > 0 and isinstance(oValue[0], ModelDataBase):
257 oValue = copy.copy(oValue);
258 for i, _ in enumerate(oValue):
259 assert isinstance(oValue[i], ModelDataBase);
260 oValue[i] = copy.copy(oValue[i]);
261 oValue[i].convertToParamNull();
262
263 elif isinstance(oValue, ModelDataBase):
264 oValue = copy.copy(oValue);
265 oValue.convertToParamNull();
266
267 return oValue;
268
269 def convertToParamNull(self):
270 """
271 Converts from database NULL values (None) to special values we can
272 pass thru parameters list.
273 Returns self.
274 """
275 for sAttr in self.getDataAttributes():
276 oValue = getattr(self, sAttr);
277 oNewValue = self._convertAttributeToParamNull(sAttr, oValue);
278 if oValue != oNewValue:
279 setattr(self, sAttr, oNewValue);
280 return self;
281
282 def _validateAndConvertAttribute(self, sAttr, sParam, oValue, aoNilValues, fAllowNull, oDb):
283 """
284 Validates and convert one attribute.
285 Returns the converted value.
286
287 Child classes can override this to handle one or more attributes specially.
288 Note! oDb can be None.
289 """
290 sPrefix = self.getHungarianPrefix(sAttr);
291
292 if sPrefix in ['id', 'uid']:
293 (oNewValue, sError) = self.validateInt( oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull);
294 elif sPrefix in ['i', 'off', 'pct']:
295 (oNewValue, sError) = self.validateInt( oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull,
296 iMin = getattr(self, 'kiMin_' + sAttr, 0),
297 iMax = getattr(self, 'kiMax_' + sAttr, 0x7ffffffe));
298 elif sPrefix in ['l', 'c']:
299 (oNewValue, sError) = self.validateLong(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull,
300 lMin = getattr(self, 'klMin_' + sAttr, 0),
301 lMax = getattr(self, 'klMax_' + sAttr, None));
302 elif sPrefix == 'f':
303 if oValue is '' and not fAllowNull: oValue = '0'; # HACK ALERT! Checkboxes are only added when checked.
304 (oNewValue, sError) = self.validateBool(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull);
305 elif sPrefix == 'ts':
306 (oNewValue, sError) = self.validateTs( oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull);
307 elif sPrefix == 'ip':
308 (oNewValue, sError) = self.validateIp( oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull);
309 elif sPrefix == 'uuid':
310 (oNewValue, sError) = self.validateUuid(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull);
311 elif sPrefix == 'enm':
312 (oNewValue, sError) = self.validateWord(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull,
313 asValid = getattr(self, 'kasValidValues_' + sAttr)); # The list is required.
314 elif sPrefix == 's':
315 (oNewValue, sError) = self.validateStr( oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull,
316 cchMin = getattr(self, 'kcchMin_' + sAttr, 0),
317 cchMax = getattr(self, 'kcchMax_' + sAttr, 4096),
318 fAllowUnicodeSymbols = getattr(self, 'kfAllowUnicode_' + sAttr, False) );
319 ## @todo al.
320 elif sPrefix == 'aid':
321 (oNewValue, sError) = self.validateListOfInts(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull,
322 iMin = 1, iMax = 0x7ffffffe);
323 elif sPrefix == 'as':
324 (oNewValue, sError) = self.validateListOfStr(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull,
325 asValidValues = getattr(self, 'kasValidValues_' + sAttr, None),
326 cchMin = getattr(self, 'kcchMin_' + sAttr, 0 if fAllowNull else 1),
327 cchMax = getattr(self, 'kcchMax_' + sAttr, 4096));
328
329 elif sPrefix == 'bm':
330 ## @todo figure out bitfields.
331 (oNewValue, sError) = self.validateListOfStr(oValue, aoNilValues = aoNilValues, fAllowNull = fAllowNull);
332 else:
333 raise TMExceptionBase('Unable to classify "%s" (prefix %s)' % (sAttr, sPrefix));
334
335 _ = sParam; _ = oDb;
336 return (oNewValue, sError);
337
338 def _validateAndConvertWorker(self, asAllowNullAttributes, oDb, enmValidateFor = ksValidateFor_Other):
339 """
340 Worker for implementing validateAndConvert().
341 """
342 dErrors = dict();
343 for sAttr in self.getDataAttributes():
344 oValue = getattr(self, sAttr);
345 sParam = getattr(self, 'ksParam_' + sAttr);
346 aoNilValues = self.getAttributeParamNullValues(sAttr);
347 aoNilValues.append(None);
348
349 (oNewValue, sError) = self._validateAndConvertAttribute(sAttr, sParam, oValue, aoNilValues,
350 sAttr in asAllowNullAttributes, oDb);
351 if oValue != oNewValue:
352 setattr(self, sAttr, oNewValue);
353 if sError is not None:
354 dErrors[sParam] = sError;
355
356 # Check the NULL requirements of the primary ID(s) for the 'add' and 'edit' actions.
357 if enmValidateFor == ModelDataBase.ksValidateFor_Add \
358 or enmValidateFor == ModelDataBase.ksValidateFor_AddForeignId \
359 or enmValidateFor == ModelDataBase.ksValidateFor_Edit:
360 fMustBeNull = enmValidateFor == ModelDataBase.ksValidateFor_Add;
361 sAttr = getattr(self, 'ksIdAttr', None);
362 if sAttr is not None:
363 oValue = getattr(self, sAttr);
364 if self.isAttributeNull(sAttr, oValue) != fMustBeNull:
365 sParam = getattr(self, 'ksParam_' + sAttr);
366 sErrMsg = 'Must be NULL!' if fMustBeNull else 'Must not be NULL!'
367 if sParam in dErrors:
368 dErrors[sParam] += ' ' + sErrMsg;
369 else:
370 dErrors[sParam] = sErrMsg;
371
372 return dErrors;
373
374 def validateAndConvert(self, oDb, enmValidateFor = ksValidateFor_Other):
375 """
376 Validates the input and converts valid fields to their right type.
377 Returns a dictionary with per field reports, only invalid fields will
378 be returned, so an empty dictionary means that the data is valid.
379
380 The dictionary keys are ksParam_*.
381
382 Child classes can override _validateAndConvertAttribute to handle
383 selected fields specially. There are also a few class variables that
384 can be used to advice the validation: kcchMin_sAttr, kcchMax_sAttr,
385 kiMin_iAttr, kiMax_iAttr, klMin_lAttr, klMax_lAttr,
386 kasValidValues_enmAttr, and kasAllowNullAttributes.
387 """
388 return self._validateAndConvertWorker(getattr(self, 'kasAllowNullAttributes', list()), oDb,
389 enmValidateFor = enmValidateFor);
390
391 def convertParamToAttribute(self, sAttr, sParam, oValue, oDisp, fStrict):
392 """
393 Calculate the attribute value when initialized from a parameter.
394
395 Returns the new value, with parameter NULL values. Raises exception on
396 invalid parameter value.
397
398 Child classes can override to do special parameter conversion jobs.
399 """
400 sPrefix = self.getHungarianPrefix(sAttr);
401 asValidValues = getattr(self, 'kasValidValues_' + sAttr, None);
402 if fStrict:
403 if sPrefix == 'f':
404 # HACK ALERT! Checkboxes are only present when checked, so we always have to provide a default.
405 oNewValue = oDisp.getStringParam(sParam, asValidValues, '0');
406 elif sPrefix[0] == 'a':
407 # HACK ALERT! List are not present if empty.
408 oNewValue = oDisp.getListOfStrParams(sParam, []);
409 else:
410 oNewValue = oDisp.getStringParam(sParam, asValidValues, None);
411 else:
412 if sPrefix[0] == 'a':
413 oNewValue = oDisp.getListOfStrParams(sParam, []);
414 else:
415 assert oValue is not None, 'sAttr=%s' % (sAttr,);
416 oNewValue = oDisp.getStringParam(sParam, asValidValues, oValue);
417 return oNewValue;
418
419 def initFromParams(self, oDisp, fStrict = True):
420 """
421 Initialize the object from parameters.
422 The input is not validated at all, except that all parameters must be
423 present when fStrict is True.
424
425 Returns self. Raises exception on invalid parameter value.
426
427 Note! The returned object has parameter NULL values, not database ones!
428 """
429
430 self.convertToParamNull()
431 for sAttr in self.getDataAttributes():
432 oValue = getattr(self, sAttr);
433 oNewValue = self.convertParamToAttribute(sAttr, getattr(self, 'ksParam_' + sAttr), oValue, oDisp, fStrict);
434 if oNewValue != oValue:
435 setattr(self, sAttr, oNewValue);
436 return self;
437
438 def areAttributeValuesEqual(self, sAttr, sPrefix, oValue1, oValue2):
439 """
440 Called to compare two attribute values and python thinks differs.
441
442 Returns True/False.
443
444 Child classes can override this to do special compares of things like arrays.
445 """
446 # Just in case someone uses it directly.
447 if oValue1 == oValue2:
448 return True;
449
450 #
451 # Timestamps can be both string (param) and object (db)
452 # depending on the data source. Compare string values to make
453 # sure we're doing the right thing here.
454 #
455 if sPrefix == 'ts':
456 return str(oValue1) == str(oValue2);
457
458 #
459 # Some generic code handling ModelDataBase children.
460 #
461 if isinstance(oValue1, list) and isinstance(oValue2, list):
462 if len(oValue1) == len(oValue2):
463 for i, _ in enumerate(oValue1):
464 if not isinstance(oValue1[i], ModelDataBase) \
465 or type(oValue1) is not type(oValue2):
466 return False;
467 if not oValue1[i].isEqual(oValue2[i]):
468 return False;
469 return True;
470
471 elif isinstance(oValue1, ModelDataBase) \
472 and type(oValue1) is type(oValue2):
473 return oValue1[i].isEqual(oValue2[i]);
474
475 _ = sAttr;
476 return False;
477
478 def isEqual(self, oOther):
479 """ Compares two instances. """
480 for sAttr in self.getDataAttributes():
481 if getattr(self, sAttr) != getattr(oOther, sAttr):
482 # Delegate the final decision to an overridable method.
483 if not self.areAttributeValuesEqual(sAttr, self.getHungarianPrefix(sAttr),
484 getattr(self, sAttr), getattr(oOther, sAttr)):
485 return False;
486 return True;
487
488 def isEqualEx(self, oOther, asExcludeAttrs):
489 """ Compares two instances, omitting the given attributes. """
490 for sAttr in self.getDataAttributes():
491 if sAttr not in asExcludeAttrs \
492 and getattr(self, sAttr) != getattr(oOther, sAttr):
493 # Delegate the final decision to an overridable method.
494 if not self.areAttributeValuesEqual(sAttr, self.getHungarianPrefix(sAttr),
495 getattr(self, sAttr), getattr(oOther, sAttr)):
496 return False;
497 return True;
498
499 def reinitToNull(self):
500 """
501 Reinitializes the object to (database) NULL values.
502 Returns self.
503 """
504 for sAttr in self.getDataAttributes():
505 setattr(self, sAttr, None);
506 return self;
507
508 def toString(self):
509 """
510 Stringifies the object.
511 Returns string representation.
512 """
513
514 sMembers = '';
515 for sAttr in self.getDataAttributes():
516 oValue = getattr(self, sAttr);
517 sMembers += ', %s=%s' % (sAttr, oValue);
518
519 oClass = type(self);
520 if sMembers == '':
521 return '<%s>' % (oClass.__name__);
522 return '<%s: %s>' % (oClass.__name__, sMembers[2:]);
523
524 def __str__(self):
525 return self.toString();
526
527
528
529 #
530 # New validation helpers.
531 #
532 # These all return (oValue, sError), where sError is None when the value
533 # is valid and an error message when not. On success and in case of
534 # range errors, oValue is converted into the requested type.
535 #
536
537 @staticmethod
538 def validateInt(sValue, iMin = 0, iMax = 0x7ffffffe, aoNilValues = tuple([-1, None, '']), fAllowNull = True):
539 """ Validates an integer field. """
540 if sValue in aoNilValues:
541 if fAllowNull:
542 return (None if sValue is None else aoNilValues[0], None);
543 return (sValue, 'Mandatory.');
544
545 try:
546 if utils.isString(sValue):
547 iValue = int(sValue, 0);
548 else:
549 iValue = int(sValue);
550 except:
551 return (sValue, 'Not an integer');
552
553 if iValue in aoNilValues:
554 return (aoNilValues[0], None if fAllowNull else 'Mandatory.');
555
556 if iValue < iMin:
557 return (iValue, 'Value too small (min %d)' % (iMin,));
558 elif iValue > iMax:
559 return (iValue, 'Value too high (max %d)' % (iMax,));
560 return (iValue, None);
561
562 @staticmethod
563 def validateLong(sValue, lMin = 0, lMax = None, aoNilValues = tuple([long(-1), None, '']), fAllowNull = True):
564 """ Validates an long integer field. """
565 if sValue in aoNilValues:
566 if fAllowNull:
567 return (None if sValue is None else aoNilValues[0], None);
568 return (sValue, 'Mandatory.');
569 try:
570 if utils.isString(sValue):
571 lValue = long(sValue, 0);
572 else:
573 lValue = long(sValue);
574 except:
575 return (sValue, 'Not a long integer');
576
577 if lValue in aoNilValues:
578 return (aoNilValues[0], None if fAllowNull else 'Mandatory.');
579
580 if lMin is not None and lValue < lMin:
581 return (lValue, 'Value too small (min %d)' % (lMin,));
582 elif lMax is not None and lValue > lMax:
583 return (lValue, 'Value too high (max %d)' % (lMax,));
584 return (lValue, None);
585
586 @staticmethod
587 def validateTs(sValue, aoNilValues = tuple([None, '']), fAllowNull = True):
588 """ Validates a timestamp field. """
589 if sValue in aoNilValues:
590 return (sValue, None if fAllowNull else 'Mandatory.');
591 if not utils.isString(sValue):
592 return (sValue, None);
593
594 sError = None;
595 if len(sValue) == len('2012-10-08 01:54:06.364207+02:00'):
596 oRes = re.match(r'(\d{4})-([01]\d)-([0123])\d ([012]\d):[0-5]\d:([0-6]\d).\d{6}[+-](\d\d):(\d\d)', sValue);
597 if oRes is not None \
598 and ( int(oRes.group(6)) > 12 \
599 or int(oRes.group(7)) >= 60):
600 sError = 'Invalid timezone offset.';
601 elif len(sValue) == len('2012-10-08 01:54:06.00'):
602 oRes = re.match(r'(\d{4})-([01]\d)-([0123])\d ([012]\d):[0-5]\d:([0-6]\d).\d{2}', sValue);
603 elif len(sValue) == len('9999-12-31 23:59:59.999999'):
604 oRes = re.match(r'(\d{4})-([01]\d)-([0123])\d ([012]\d):[0-5]\d:([0-6]\d).\d{6}', sValue);
605 elif len(sValue) == len('999999-12-31 00:00:00.00'):
606 oRes = re.match(r'(\d{6})-([01]\d)-([0123])\d ([012]\d):[0-5]\d:([0-6]\d).\d{2}', sValue);
607 elif len(sValue) == len('9999-12-31T23:59:59.999999Z'):
608 oRes = re.match(r'(\d{4})-([01]\d)-([0123])\d[Tt]([012]\d):[0-5]\d:([0-6]\d).\d{6}[Zz]', sValue);
609 elif len(sValue) == len('9999-12-31T23:59:59.999999999Z'):
610 oRes = re.match(r'(\d{4})-([01]\d)-([0123])\d[Tt]([012]\d):[0-5]\d:([0-6]\d).\d{9}[Zz]', sValue);
611 else:
612 return (sValue, 'Invalid timestamp length.');
613
614 if oRes is None:
615 sError = 'Invalid timestamp (format: 2012-10-08 01:54:06.364207+02:00).';
616 else:
617 iYear = int(oRes.group(1));
618 if iYear % 4 == 0 and (iYear % 100 != 0 or iYear % 400 == 0):
619 acDaysOfMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
620 else:
621 acDaysOfMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
622 iMonth = int(oRes.group(2));
623 iDay = int(oRes.group(3));
624 iHour = int(oRes.group(4));
625 iSec = int(oRes.group(5));
626 if iMonth > 12:
627 sError = 'Invalid timestamp month.';
628 elif iDay > acDaysOfMonth[iMonth - 1]:
629 sError = 'Invalid timestamp day-of-month (%02d has %d days).' % (iMonth, acDaysOfMonth[iMonth - 1]);
630 elif iHour > 23:
631 sError = 'Invalid timestamp hour.'
632 elif iSec >= 61:
633 sError = 'Invalid timestamp second.'
634 elif iSec >= 60:
635 sError = 'Invalid timestamp: no leap seconds, please.'
636 return (sValue, sError);
637
638 @staticmethod
639 def validateIp(sValue, aoNilValues = tuple([None, '']), fAllowNull = True):
640 """ Validates an IP address field. """
641 if sValue in aoNilValues:
642 return (sValue, None if fAllowNull else 'Mandatory.');
643
644 if sValue == '::1':
645 return (sValue, None);
646
647 try:
648 socket.inet_pton(socket.AF_INET, sValue); # pylint: disable=E1101
649 except:
650 try:
651 socket.inet_pton(socket.AF_INET6, sValue); # pylint: disable=E1101
652 except:
653 return (sValue, 'Not a valid IP address.');
654
655 return (sValue, None);
656
657 @staticmethod
658 def validateBool(sValue, aoNilValues = tuple([None, '']), fAllowNull = True):
659 """ Validates a boolean field. """
660 if sValue in aoNilValues:
661 return (sValue, None if fAllowNull else 'Mandatory.');
662
663 if sValue in ('True', 'true', '1', True):
664 return (True, None);
665 if sValue in ('False', 'false', '0', False):
666 return (False, None);
667 return (sValue, 'Invalid boolean value.');
668
669 @staticmethod
670 def validateUuid(sValue, aoNilValues = tuple([None, '']), fAllowNull = True):
671 """ Validates an UUID field. """
672 if sValue in aoNilValues:
673 return (sValue, None if fAllowNull else 'Mandatory.');
674
675 try:
676 sValue = str(uuid.UUID(sValue));
677 except:
678 return (sValue, 'Invalid UUID value.');
679 return (sValue, None);
680
681 @staticmethod
682 def validateWord(sValue, cchMin = 1, cchMax = 64, asValid = None, aoNilValues = tuple([None, '']), fAllowNull = True):
683 """ Validates a word field. """
684 if sValue in aoNilValues:
685 return (sValue, None if fAllowNull else 'Mandatory.');
686
687 if re.search('[^a-zA-Z0-9_-]', sValue) is not None:
688 sError = 'Single word ([a-zA-Z0-9_-]), please.';
689 elif cchMin is not None and len(sValue) < cchMin:
690 sError = 'Too short, min %s chars' % (cchMin,);
691 elif cchMax is not None and len(sValue) > cchMax:
692 sError = 'Too long, max %s chars' % (cchMax,);
693 elif asValid is not None and sValue not in asValid:
694 sError = 'Invalid value "%s", must be one of: %s' % (sValue, asValid);
695 else:
696 sError = None;
697 return (sValue, sError);
698
699 @staticmethod
700 def validateStr(sValue, cchMin = 0, cchMax = 4096, aoNilValues = tuple([None, '']), fAllowNull = True,
701 fAllowUnicodeSymbols = False):
702 """ Validates a string field. """
703 if sValue in aoNilValues:
704 return (sValue, None if fAllowNull else 'Mandatory.');
705
706 if cchMin is not None and len(sValue) < cchMin:
707 sError = 'Too short, min %s chars' % (cchMin,);
708 elif cchMax is not None and len(sValue) > cchMax:
709 sError = 'Too long, max %s chars' % (cchMax,);
710 elif fAllowUnicodeSymbols is False and utils.hasNonAsciiCharacters(sValue):
711 sError = 'Non-ascii characters not allowed'
712 else:
713 sError = None;
714 return (sValue, sError);
715
716 @staticmethod
717 def validateEmail(sValue, aoNilValues = tuple([None, '']), fAllowNull = True):
718 """ Validates a email field."""
719 if sValue in aoNilValues:
720 return (sValue, None if fAllowNull else 'Mandatory.');
721
722 if re.match(r'.+@.+\..+', sValue) is None:
723 return (sValue,'Invalid e-mail format.');
724 return (sValue, None);
725
726 @staticmethod
727 def validateListOfSomething(asValues, aoNilValues = tuple([[], None]), fAllowNull = True):
728 """ Validate a list of some uniform values. Returns a copy of the list (if list it is). """
729 if asValues in aoNilValues or (len(asValues) == 0 and not fAllowNull):
730 return (asValues, None if fAllowNull else 'Mandatory.')
731
732 if not isinstance(asValues, list):
733 return (asValues, 'Invalid data type (%s).' % (type(asValues),));
734
735 asValues = list(asValues); # copy the list.
736 if len(asValues) > 0:
737 oType = type(asValues[0]);
738 for i in range(1, len(asValues)):
739 if type(asValues[i]) is not oType: # pylint: disable=C0123
740 return (asValues, 'Invalid entry data type ([0]=%s vs [%d]=%s).' % (oType, i, type(asValues[i])) );
741
742 return (asValues, None);
743
744 @staticmethod
745 def validateListOfStr(asValues, cchMin = None, cchMax = None, asValidValues = None,
746 aoNilValues = tuple([[], None]), fAllowNull = True):
747 """ Validates a list of text items."""
748 (asValues, sError) = ModelDataBase.validateListOfSomething(asValues, aoNilValues, fAllowNull);
749
750 if sError is None and asValues not in aoNilValues and len(asValues) > 0:
751 if not utils.isString(asValues[0]):
752 return (asValues, 'Invalid item data type.');
753
754 if not fAllowNull and cchMin is None:
755 cchMin = 1;
756
757 for sValue in asValues:
758 if asValidValues is not None and sValue not in asValidValues:
759 sThisErr = 'Invalid value "%s".' % (sValue,);
760 elif cchMin is not None and len(sValue) < cchMin:
761 sThisErr = 'Value "%s" is too short, min length is %u chars.' % (sValue, cchMin);
762 elif cchMax is not None and len(sValue) > cchMax:
763 sThisErr = 'Value "%s" is too long, max length is %u chars.' % (sValue, cchMax);
764 else:
765 continue;
766
767 if sError is None:
768 sError = sThisErr;
769 else:
770 sError += ' ' + sThisErr;
771
772 return (asValues, sError);
773
774 @staticmethod
775 def validateListOfInts(asValues, iMin = 0, iMax = 0x7ffffffe, aoNilValues = tuple([[], None]), fAllowNull = True):
776 """ Validates a list of integer items."""
777 (asValues, sError) = ModelDataBase.validateListOfSomething(asValues, aoNilValues, fAllowNull);
778
779 if sError is None and asValues not in aoNilValues and len(asValues) > 0:
780 for i, _ in enumerate(asValues):
781 sValue = asValues[i];
782
783 sThisErr = '';
784 try:
785 iValue = int(sValue);
786 except:
787 sThisErr = 'Invalid integer value "%s".' % (sValue,);
788 else:
789 asValues[i] = iValue;
790 if iValue < iMin:
791 sThisErr = 'Value %d is too small (min %d)' % (iValue, iMin,);
792 elif iValue > iMax:
793 sThisErr = 'Value %d is too high (max %d)' % (iValue, iMax,);
794 else:
795 continue;
796
797 if sError is None:
798 sError = sThisErr;
799 else:
800 sError += ' ' + sThisErr;
801
802 return (asValues, sError);
803
804
805
806 #
807 # Old validation helpers.
808 #
809
810 @staticmethod
811 def _validateInt(dErrors, sName, sValue, iMin = 0, iMax = 0x7ffffffe, aoNilValues = tuple([-1, None, ''])):
812 """ Validates an integer field. """
813 (sValue, sError) = ModelDataBase.validateInt(sValue, iMin, iMax, aoNilValues, fAllowNull = True);
814 if sError is not None:
815 dErrors[sName] = sError;
816 return sValue;
817
818 @staticmethod
819 def _validateIntNN(dErrors, sName, sValue, iMin = 0, iMax = 0x7ffffffe, aoNilValues = tuple([-1, None, ''])):
820 """ Validates an integer field, not null. """
821 (sValue, sError) = ModelDataBase.validateInt(sValue, iMin, iMax, aoNilValues, fAllowNull = False);
822 if sError is not None:
823 dErrors[sName] = sError;
824 return sValue;
825
826 @staticmethod
827 def _validateLong(dErrors, sName, sValue, lMin = 0, lMax = None, aoNilValues = tuple([long(-1), None, ''])):
828 """ Validates an long integer field. """
829 (sValue, sError) = ModelDataBase.validateLong(sValue, lMin, lMax, aoNilValues, fAllowNull = False);
830 if sError is not None:
831 dErrors[sName] = sError;
832 return sValue;
833
834 @staticmethod
835 def _validateLongNN(dErrors, sName, sValue, lMin = 0, lMax = None, aoNilValues = tuple([long(-1), None, ''])):
836 """ Validates an long integer field, not null. """
837 (sValue, sError) = ModelDataBase.validateLong(sValue, lMin, lMax, aoNilValues, fAllowNull = True);
838 if sError is not None:
839 dErrors[sName] = sError;
840 return sValue;
841
842 @staticmethod
843 def _validateTs(dErrors, sName, sValue):
844 """ Validates a timestamp field. """
845 (sValue, sError) = ModelDataBase.validateTs(sValue, fAllowNull = True);
846 if sError is not None:
847 dErrors[sName] = sError;
848 return sValue;
849
850 @staticmethod
851 def _validateTsNN(dErrors, sName, sValue):
852 """ Validates a timestamp field, not null. """
853 (sValue, sError) = ModelDataBase.validateTs(sValue, fAllowNull = False);
854 if sError is not None:
855 dErrors[sName] = sError;
856 return sValue;
857
858 @staticmethod
859 def _validateIp(dErrors, sName, sValue):
860 """ Validates an IP address field. """
861 (sValue, sError) = ModelDataBase.validateIp(sValue, fAllowNull = True);
862 if sError is not None:
863 dErrors[sName] = sError;
864 return sValue;
865
866 @staticmethod
867 def _validateIpNN(dErrors, sName, sValue):
868 """ Validates an IP address field, not null. """
869 (sValue, sError) = ModelDataBase.validateIp(sValue, fAllowNull = False);
870 if sError is not None:
871 dErrors[sName] = sError;
872 return sValue;
873
874 @staticmethod
875 def _validateBool(dErrors, sName, sValue):
876 """ Validates a boolean field. """
877 (sValue, sError) = ModelDataBase.validateBool(sValue, fAllowNull = True);
878 if sError is not None:
879 dErrors[sName] = sError;
880 return sValue;
881
882 @staticmethod
883 def _validateBoolNN(dErrors, sName, sValue):
884 """ Validates a boolean field, not null. """
885 (sValue, sError) = ModelDataBase.validateBool(sValue, fAllowNull = False);
886 if sError is not None:
887 dErrors[sName] = sError;
888 return sValue;
889
890 @staticmethod
891 def _validateUuid(dErrors, sName, sValue):
892 """ Validates an UUID field. """
893 (sValue, sError) = ModelDataBase.validateUuid(sValue, fAllowNull = True);
894 if sError is not None:
895 dErrors[sName] = sError;
896 return sValue;
897
898 @staticmethod
899 def _validateUuidNN(dErrors, sName, sValue):
900 """ Validates an UUID field, not null. """
901 (sValue, sError) = ModelDataBase.validateUuid(sValue, fAllowNull = False);
902 if sError is not None:
903 dErrors[sName] = sError;
904 return sValue;
905
906 @staticmethod
907 def _validateWord(dErrors, sName, sValue, cchMin = 1, cchMax = 64, asValid = None):
908 """ Validates a word field. """
909 (sValue, sError) = ModelDataBase.validateWord(sValue, cchMin, cchMax, asValid, fAllowNull = True);
910 if sError is not None:
911 dErrors[sName] = sError;
912 return sValue;
913
914 @staticmethod
915 def _validateWordNN(dErrors, sName, sValue, cchMin = 1, cchMax = 64, asValid = None):
916 """ Validates a boolean field, not null. """
917 (sValue, sError) = ModelDataBase.validateWord(sValue, cchMin, cchMax, asValid, fAllowNull = False);
918 if sError is not None:
919 dErrors[sName] = sError;
920 return sValue;
921
922 @staticmethod
923 def _validateStr(dErrors, sName, sValue, cchMin = 0, cchMax = 4096):
924 """ Validates a string field. """
925 (sValue, sError) = ModelDataBase.validateStr(sValue, cchMin, cchMax, fAllowNull = True);
926 if sError is not None:
927 dErrors[sName] = sError;
928 return sValue;
929
930 @staticmethod
931 def _validateStrNN(dErrors, sName, sValue, cchMin = 0, cchMax = 4096):
932 """ Validates a string field, not null. """
933 (sValue, sError) = ModelDataBase.validateStr(sValue, cchMin, cchMax, fAllowNull = False);
934 if sError is not None:
935 dErrors[sName] = sError;
936 return sValue;
937
938 @staticmethod
939 def _validateEmail(dErrors, sName, sValue):
940 """ Validates a email field."""
941 (sValue, sError) = ModelDataBase.validateEmail(sValue, fAllowNull = True);
942 if sError is not None:
943 dErrors[sName] = sError;
944 return sValue;
945
946 @staticmethod
947 def _validateEmailNN(dErrors, sName, sValue):
948 """ Validates a email field."""
949 (sValue, sError) = ModelDataBase.validateEmail(sValue, fAllowNull = False);
950 if sError is not None:
951 dErrors[sName] = sError;
952 return sValue;
953
954 @staticmethod
955 def _validateListOfStr(dErrors, sName, asValues, asValidValues = None):
956 """ Validates a list of text items."""
957 (sValue, sError) = ModelDataBase.validateListOfStr(asValues, asValidValues = asValidValues, fAllowNull = True);
958 if sError is not None:
959 dErrors[sName] = sError;
960 return sValue;
961
962 @staticmethod
963 def _validateListOfStrNN(dErrors, sName, asValues, asValidValues = None):
964 """ Validates a list of text items, not null and len >= 1."""
965 (sValue, sError) = ModelDataBase.validateListOfStr(asValues, asValidValues = asValidValues, fAllowNull = False);
966 if sError is not None:
967 dErrors[sName] = sError;
968 return sValue;
969
970 #
971 # Various helpers.
972 #
973
974 @staticmethod
975 def formatSimpleNowAndPeriod(oDb, tsNow = None, sPeriodBack = None,
976 sTablePrefix = '', sExpCol = 'tsExpire', sEffCol = 'tsEffective'):
977 """
978 Formats a set of tsNow and sPeriodBack arguments for a standard testmanager
979 table.
980
981 If sPeriodBack is given, the query is effective for the period
982 (tsNow - sPeriodBack) thru (tsNow).
983
984 If tsNow isn't given, it defaults to current time.
985
986 Returns the final portion of a WHERE query (start with AND) and maybe an
987 ORDER BY and LIMIT bit if sPeriodBack is given.
988 """
989 if tsNow is not None:
990 if sPeriodBack is not None:
991 sRet = oDb.formatBindArgs(' AND ' + sTablePrefix + sExpCol + ' > (%s::timestamp - %s::interval)\n'
992 ' AND tsEffective <= %s\n'
993 'ORDER BY ' + sTablePrefix + sExpCol + ' DESC\n'
994 'LIMIT 1\n'
995 , ( tsNow, sPeriodBack, tsNow));
996 else:
997 sRet = oDb.formatBindArgs(' AND ' + sTablePrefix + sExpCol + ' > %s\n'
998 ' AND ' + sTablePrefix + sEffCol + ' <= %s\n'
999 , ( tsNow, tsNow, ));
1000 else:
1001 if sPeriodBack is not None:
1002 sRet = oDb.formatBindArgs(' AND ' + sTablePrefix + sExpCol + ' > (CURRENT_TIMESTAMP - %s::interval)\n'
1003 ' AND ' + sTablePrefix + sEffCol + ' <= CURRENT_TIMESTAMP\n'
1004 'ORDER BY ' + sTablePrefix + sExpCol + ' DESC\n'
1005 'LIMIT 1\n'
1006 , ( sPeriodBack, ));
1007 else:
1008 sRet = ' AND ' + sTablePrefix + sExpCol + ' = \'infinity\'::timestamp\n';
1009 return sRet;
1010
1011 @staticmethod
1012 def formatSimpleNowAndPeriodQuery(oDb, sQuery, aBindArgs, tsNow = None, sPeriodBack = None,
1013 sTablePrefix = '', sExpCol = 'tsExpire', sEffCol = 'tsEffective'):
1014 """
1015 Formats a simple query for a standard testmanager table with optional
1016 tsNow and sPeriodBack arguments.
1017
1018 The sQuery and sBindArgs are passed along to oDb.formatBindArgs to form
1019 the first part of the query. Must end with an open WHERE statement as
1020 we'll be adding the time part starting with 'AND something...'.
1021
1022 See formatSimpleNowAndPeriod for tsNow and sPeriodBack description.
1023
1024 Returns the final portion of a WHERE query (start with AND) and maybe an
1025 ORDER BY and LIMIT bit if sPeriodBack is given.
1026
1027 """
1028 return oDb.formatBindArgs(sQuery, aBindArgs) \
1029 + ModelDataBase.formatSimpleNowAndPeriod(oDb, tsNow, sPeriodBack, sTablePrefix, sExpCol, sEffCol);
1030
1031 #
1032 # Sub-classes.
1033 #
1034
1035 class DispWrapper(object):
1036 """Proxy object."""
1037 def __init__(self, oDisp, sAttrFmt):
1038 self.oDisp = oDisp;
1039 self.sAttrFmt = sAttrFmt;
1040 def getStringParam(self, sName, asValidValues = None, sDefault = None):
1041 """See WuiDispatcherBase.getStringParam."""
1042 return self.oDisp.getStringParam(self.sAttrFmt % (sName,), asValidValues, sDefault);
1043 def getListOfStrParams(self, sName, asDefaults = None):
1044 """See WuiDispatcherBase.getListOfStrParams."""
1045 return self.oDisp.getListOfStrParams(self.sAttrFmt % (sName,), asDefaults);
1046 def getListOfIntParams(self, sName, iMin = None, iMax = None, aiDefaults = None):
1047 """See WuiDispatcherBase.getListOfIntParams."""
1048 return self.oDisp.getListOfIntParams(self.sAttrFmt % (sName,), iMin, iMax, aiDefaults);
1049
1050
1051
1052
1053# pylint: disable=E1101,C0111,R0903
1054class ModelDataBaseTestCase(unittest.TestCase):
1055 """
1056 Base testcase for ModelDataBase decendants.
1057 Derive from this and override setUp.
1058 """
1059
1060 def setUp(self):
1061 """
1062 Override this! Don't call super!
1063 The subclasses are expected to set aoSamples to an array of instance
1064 samples. The first entry must be a default object, the subsequent ones
1065 are optional and their contents freely choosen.
1066 """
1067 self.aoSamples = [ModelDataBase(),];
1068
1069 def testEquality(self):
1070 for oSample in self.aoSamples:
1071 self.assertEqual(oSample.isEqual(copy.copy(oSample)), True);
1072 self.assertIsNotNone(oSample.isEqual(self.aoSamples[0]));
1073
1074 def testNullConversion(self):
1075 if len(self.aoSamples[0].getDataAttributes()) == 0:
1076 return;
1077 for oSample in self.aoSamples:
1078 oCopy = copy.copy(oSample);
1079 self.assertEqual(oCopy.convertToParamNull(), oCopy);
1080 self.assertEqual(oCopy.isEqual(oSample), False);
1081 self.assertEqual(oCopy.convertFromParamNull(), oCopy);
1082 self.assertEqual(oCopy.isEqual(oSample), True, '\ngot : %s\nexpected: %s' % (oCopy, oSample,));
1083
1084 oCopy = copy.copy(oSample);
1085 self.assertEqual(oCopy.convertToParamNull(), oCopy);
1086 oCopy2 = copy.copy(oCopy);
1087 self.assertEqual(oCopy.convertToParamNull(), oCopy);
1088 self.assertEqual(oCopy.isEqual(oCopy2), True);
1089 self.assertEqual(oCopy.convertToParamNull(), oCopy);
1090 self.assertEqual(oCopy.isEqual(oCopy2), True);
1091
1092 oCopy = copy.copy(oSample);
1093 self.assertEqual(oCopy.convertFromParamNull(), oCopy);
1094 oCopy2 = copy.copy(oCopy);
1095 self.assertEqual(oCopy.convertFromParamNull(), oCopy);
1096 self.assertEqual(oCopy.isEqual(oCopy2), True);
1097 self.assertEqual(oCopy.convertFromParamNull(), oCopy);
1098 self.assertEqual(oCopy.isEqual(oCopy2), True);
1099
1100 def testReinitToNull(self):
1101 oFirst = copy.copy(self.aoSamples[0]);
1102 self.assertEqual(oFirst.reinitToNull(), oFirst);
1103 for oSample in self.aoSamples:
1104 oCopy = copy.copy(oSample);
1105 self.assertEqual(oCopy.reinitToNull(), oCopy);
1106 self.assertEqual(oCopy.isEqual(oFirst), True);
1107
1108 def testValidateAndConvert(self):
1109 for oSample in self.aoSamples:
1110 oCopy = copy.copy(oSample);
1111 oCopy.convertToParamNull();
1112 dError1 = oCopy.validateAndConvert(None);
1113
1114 oCopy2 = copy.copy(oCopy);
1115 self.assertEqual(oCopy.validateAndConvert(None), dError1);
1116 self.assertEqual(oCopy.isEqual(oCopy2), True);
1117
1118 def testInitFromParams(self):
1119 class DummyDisp(object):
1120 def getStringParam(self, sName, asValidValues = None, sDefault = None):
1121 _ = sName; _ = asValidValues;
1122 return sDefault;
1123 def getListOfStrParams(self, sName, asDefaults = None):
1124 _ = sName;
1125 return asDefaults;
1126 def getListOfIntParams(self, sName, iMin = None, iMax = None, aiDefaults = None):
1127 _ = sName; _ = iMin; _ = iMax;
1128 return aiDefaults;
1129
1130 for oSample in self.aoSamples:
1131 oCopy = copy.copy(oSample);
1132 self.assertEqual(oCopy.initFromParams(DummyDisp(), fStrict = False), oCopy);
1133
1134 def testToString(self):
1135 for oSample in self.aoSamples:
1136 self.assertIsNotNone(oSample.toString());
1137
1138
1139class ModelLogicBase(ModelBase): # pylint: disable=R0903
1140 """
1141 Something all classes in the logic classes the logical model inherits from.
1142 """
1143
1144 def __init__(self, oDb):
1145 ModelBase.__init__(self);
1146
1147 #
1148 # Note! Do not create a connection here if None, we need to DB share
1149 # connection with all other logic objects so we can perform half
1150 # complex transactions involving several logic objects.
1151 #
1152 self._oDb = oDb;
1153
1154 def getDbConnection(self):
1155 """
1156 Gets the database connection.
1157 This should only be used for instantiating other ModelLogicBase children.
1158 """
1159 return self._oDb;
1160
1161
1162class AttributeChangeEntry(object): # pylint: disable=R0903
1163 """
1164 Data class representing the changes made to one attribute.
1165 """
1166
1167 def __init__(self, sAttr, oNewRaw, oOldRaw, sNewText, sOldText):
1168 self.sAttr = sAttr;
1169 self.oNewRaw = oNewRaw;
1170 self.oOldRaw = oOldRaw;
1171 self.sNewText = sNewText;
1172 self.sOldText = sOldText;
1173
1174class ChangeLogEntry(object): # pylint: disable=R0903
1175 """
1176 A change log entry returned by the fetchChangeLog method typically
1177 implemented by ModelLogicBase child classes.
1178 """
1179
1180 def __init__(self, uidAuthor, sAuthor, tsEffective, tsExpire, oNewRaw, oOldRaw, aoChanges):
1181 self.uidAuthor = uidAuthor;
1182 self.sAuthor = sAuthor;
1183 self.tsEffective = tsEffective;
1184 self.tsExpire = tsExpire;
1185 self.oNewRaw = oNewRaw;
1186 self.oOldRaw = oOldRaw; # Note! NULL for the last entry.
1187 self.aoChanges = aoChanges;
1188
Note: See TracBrowser for help on using the repository browser.

© 2025 Oracle Support Privacy / Do Not Sell My Info Terms of Use Trademark Policy Automated Access Etiquette