diff --git a/CHANGELOG b/CHANGELOG index 4ad64ce257..e6ec169608 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add strip option to Char fields * Strip only one wildcard * Manage web extension origin as null * Remove same types restriction on PYSON If diff --git a/doc/ref/fields.rst b/doc/ref/fields.rst index 5dd81ec206..7c7b04237d 100644 --- a/doc/ref/fields.rst +++ b/doc/ref/fields.rst @@ -264,7 +264,7 @@ BigInteger Char ---- -.. class:: Char(string[, size[, translate[, \**options]]]) +.. class:: Char(string[, size[, translate[, strip[, \**options]]]]) A single line :py:class:`string ` field. @@ -291,6 +291,13 @@ Char The value readed and stored will depend on the ``language`` defined in the context. +.. attribute:: Char.strip + + If ``True``, leading and trailing whitespace are removed. + If ``leading``, leading whitespace are removed. + If ``trailing``, trailing whitespace are removed. + The default value is ``True``. + .. attribute:: Char.autocomplete A set of field names. diff --git a/trytond/ir/configuration.py b/trytond/ir/configuration.py index ba542206b7..bad7f1c5d4 100644 --- a/trytond/ir/configuration.py +++ b/trytond/ir/configuration.py @@ -9,7 +9,7 @@ class Configuration(ModelSingleton, ModelSQL): 'Configuration' __name__ = 'ir.configuration' language = fields.Char('language') - hostname = fields.Char("Hostname") + hostname = fields.Char("Hostname", strip=False) _get_language_cache = Cache('ir_configuration.get_language') @staticmethod diff --git a/trytond/ir/lang.py b/trytond/ir/lang.py index d6e61e3599..d15cb32b04 100644 --- a/trytond/ir/lang.py +++ b/trytond/ir/lang.py @@ -52,24 +52,26 @@ class Lang(DeactivableMixin, ModelSQL, ModelView): ], 'Direction', required=True) # date - date = fields.Char('Date', required=True) + date = fields.Char("Date", required=True, strip=False) - am = fields.Char("AM") - pm = fields.Char("PM") + am = fields.Char("AM", strip=False) + pm = fields.Char("PM", strip=False) # number grouping = fields.Char('Grouping', required=True) - decimal_point = fields.Char('Decimal Separator', required=True) - thousands_sep = fields.Char('Thousands Separator') + decimal_point = fields.Char( + "Decimal Separator", required=True, strip=False) + thousands_sep = fields.Char("Thousands Separator", strip=False) # monetary formatting mon_grouping = fields.Char('Grouping', required=True) - mon_decimal_point = fields.Char('Decimal Separator', required=True) + mon_decimal_point = fields.Char( + "Decimal Separator", required=True, strip=False) mon_thousands_sep = fields.Char('Thousands Separator') p_sign_posn = fields.Integer('Positive Sign Position', required=True) n_sign_posn = fields.Integer('Negative Sign Position', required=True) - positive_sign = fields.Char('Positive Sign') - negative_sign = fields.Char('Negative Sign') + positive_sign = fields.Char("Positive Sign", strip=False) + negative_sign = fields.Char("Negative Sign", strip=False) p_cs_precedes = fields.Boolean('Positive Currency Symbol Precedes') n_cs_precedes = fields.Boolean('Negative Currency Symbol Precedes') p_sep_by_space = fields.Boolean('Positive Separate by Space') diff --git a/trytond/ir/sequence.py b/trytond/ir/sequence.py index f192e95514..e42d8091a2 100644 --- a/trytond/ir/sequence.py +++ b/trytond/ir/sequence.py @@ -60,8 +60,8 @@ class Sequence(DeactivableMixin, ModelSQL, ModelView): 'readonly': Eval('id', -1) >= 0, }, depends=['id']) - prefix = fields.Char('Prefix') - suffix = fields.Char('Suffix') + prefix = fields.Char('Prefix', strip='leading') + suffix = fields.Char('Suffix', strip='trailing') type = fields.Selection([ ('incremental', 'Incremental'), ('decimal timestamp', 'Decimal Timestamp'), diff --git a/trytond/ir/session.py b/trytond/ir/session.py index a52dd44f37..a2fa8d198a 100644 --- a/trytond/ir/session.py +++ b/trytond/ir/session.py @@ -13,7 +13,7 @@ class Session(ModelSQL): __name__ = 'ir.session' _rec_name = 'key' - key = fields.Char('Key', required=True, select=True) + key = fields.Char('Key', required=True, select=True, strip=False) @classmethod def __setup__(cls): diff --git a/trytond/model/fields/char.py b/trytond/model/fields/char.py index d42b2754ef..a270a08f23 100644 --- a/trytond/model/fields/char.py +++ b/trytond/model/fields/char.py @@ -1,8 +1,11 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. +import string import warnings +from sql import Expression, Query from sql.conditionals import Coalesce, NullIf +from sql.functions import Trim from sql.operators import Not from trytond.rpc import RPC @@ -25,7 +28,7 @@ class Char(FieldTranslate): def __init__(self, string='', size=None, help='', required=False, readonly=False, domain=None, states=None, translate=False, select=False, on_change=None, on_change_with=None, depends=None, - context=None, loading=None, autocomplete=None): + context=None, loading=None, autocomplete=None, strip=True): ''' :param translate: A boolean. If ``True`` the field is translatable. :param size: A integer. If set defines the maximum size of the values. @@ -41,6 +44,7 @@ class Char(FieldTranslate): warnings.warn('autocomplete argument is deprecated, use the ' 'depends decorator', DeprecationWarning, stacklevel=2) self.autocomplete.update(autocomplete) + self.strip = strip self.translate = translate self.__size = None self.size = size @@ -55,6 +59,15 @@ class Char(FieldTranslate): size = property(_get_size, _set_size) + @property + def strip(self): + return self.__strip + + @strip.setter + def strip(self, value): + assert value in {False, True, 'leading', 'trailing'} + self.__strip = value + @property def _sql_type(self): if isinstance(self.size, int): @@ -62,6 +75,36 @@ class Char(FieldTranslate): else: return 'VARCHAR' + def __set__(self, inst, value): + if isinstance(value, str) and self.strip: + if self.strip == 'leading': + value = value.lstrip() + elif self.strip == 'trailing': + value = value.rstrip() + else: + value = value.strip() + super().__set__(inst, value) + + def sql_format(self, value): + if value is not None and self.strip: + if isinstance(value, (Query, Expression)): + if self.strip == 'leading': + position = 'LEADING' + elif self.strip == 'trailing': + position = 'TRAILING' + else: + position = 'BOTH' + value = Trim( + value, position=position, characters=string.whitespace) + else: + if self.strip == 'leading': + value = value.lstrip() + elif self.strip == 'trailing': + value = value.rstrip() + else: + value = value.strip() + return super().sql_format(value) + def set_rpc(self, model): super(Char, self).set_rpc(model) if self.autocomplete: @@ -188,6 +231,7 @@ class Char(FieldTranslate): def definition(self, model, language): definition = super().definition(model, language) definition['autocomplete'] = list(self.autocomplete) + definition['strip'] = self.strip if self.size is not None: definition['size'] = self.size return definition diff --git a/trytond/model/fields/text.py b/trytond/model/fields/text.py index 46e1fd0fa3..c1e7c019cd 100644 --- a/trytond/model/fields/text.py +++ b/trytond/model/fields/text.py @@ -18,6 +18,9 @@ class Text(Char): forbidden_chars = '' search_full_text = True + def __init__(self, *args, strip=False, **kwarg): + super().__init__(*args, strip=strip, **kwarg) + class FullText(Field): ''' diff --git a/trytond/model/modelsql.py b/trytond/model/modelsql.py index 1378f533ef..6b24fbf520 100644 --- a/trytond/model/modelsql.py +++ b/trytond/model/modelsql.py @@ -698,7 +698,8 @@ class ModelSQL(ModelStorage): if (getattr(field, 'translate', False) and not hasattr(field, 'set')): translation_values.setdefault( - '%s,%s' % (cls.__name__, fname), {})[new_id] = value + '%s,%s' % (cls.__name__, fname), {})[new_id] = ( + field.sql_format(value)) if hasattr(field, 'set'): args = fields_to_set.setdefault(fname, []) actions = iter(args) @@ -1113,7 +1114,8 @@ class ModelSQL(ModelStorage): and not hasattr(field, 'set')): Translation.set_ids( '%s,%s' % (cls.__name__, fname), 'model', - transaction.language, ids, [value] * len(ids)) + transaction.language, ids, + [field.sql_format(value)] * len(ids)) if hasattr(field, 'set'): fields_to_set.setdefault(fname, []).extend((ids, value)) diff --git a/trytond/res/user.py b/trytond/res/user.py index c20d77f092..20fe1bcfe2 100644 --- a/trytond/res/user.py +++ b/trytond/res/user.py @@ -112,7 +112,7 @@ class User(avatar_mixin(100, 'login'), DeactivableMixin, ModelSQL, ModelView): __name__ = "res.user" name = fields.Char('Name', select=True) login = fields.Char('Login', required=True) - password_hash = fields.Char('Password Hash') + password_hash = fields.Char('Password Hash', strip=False) password = fields.Function(fields.Char( "Password", states={ @@ -120,7 +120,7 @@ class User(avatar_mixin(100, 'login'), DeactivableMixin, ModelSQL, ModelView): }), getter='get_password', setter='set_password') password_reset = fields.Char( - "Reset Password", + "Reset Password", strip=False, states={ 'invisible': not _has_password, }) @@ -812,7 +812,7 @@ class LoginAttempt(ModelSQL): """ __name__ = 'res.user.login.attempt' login = fields.Char('Login', size=512) - device_cookie = fields.Char("Device Cookie") + device_cookie = fields.Char("Device Cookie", strip=False) ip_address = fields.Char("IP Address") ip_network = fields.Char("IP Network") @@ -896,7 +896,7 @@ class UserDevice(ModelSQL): __name__ = 'res.user.device' login = fields.Char("Login", required=True) - cookie = fields.Char("Cookie", readonly=True, required=True) + cookie = fields.Char("Cookie", readonly=True, required=True, strip=False) @classmethod def __setup__(cls): @@ -1082,7 +1082,7 @@ class UserApplication(Workflow, ModelSQL, ModelView): __name__ = 'res.user.application' _rec_name = 'key' - key = fields.Char("Key", required=True, select=True) + key = fields.Char("Key", required=True, select=True, strip=False) user = fields.Many2One('res.user', "User", select=True) application = fields.Selection([], "Application") state = fields.Selection([ diff --git a/trytond/tests/field_char.py b/trytond/tests/field_char.py index 44c22ab339..aaa86ef20b 100644 --- a/trytond/tests/field_char.py +++ b/trytond/tests/field_char.py @@ -10,6 +10,9 @@ class Char(ModelSQL): 'Char' __name__ = 'test.char' char = fields.Char("Char") + char_lstripped = fields.Char("Char", strip='leading') + char_rstripped = fields.Char("Char", strip='trailing') + char_unstripped = fields.Char("Char", strip=False) class CharDefault(ModelSQL): @@ -47,6 +50,9 @@ class CharTranslate(ModelSQL): 'Char Translate' __name__ = 'test.char_translate' char = fields.Char("Char", translate=True) + char_lstripped = fields.Char("Char", strip='leading', translate=True) + char_rstripped = fields.Char("Char", strip='trailing', translate=True) + char_unstripped = fields.Char("Char", strip=False, translate=True) class CharUnaccentedOn(ModelSQL): diff --git a/trytond/tests/test_field_char.py b/trytond/tests/test_field_char.py index eed0032fb2..b1709398ff 100644 --- a/trytond/tests/test_field_char.py +++ b/trytond/tests/test_field_char.py @@ -323,6 +323,64 @@ class CommonTestCaseMixin: self.assertEqual(char.char, "é") + @with_transaction() + def test_create_strip(self): + "Test create with stripping" + Char = self.Char() + + char, = Char.create([{ + 'char': " Foo ", + 'char_lstripped': " Foo ", + 'char_rstripped': " Foo ", + 'char_unstripped': " Foo ", + }]) + + read_record = Char(char.id) + self.assertEqual(read_record.char, "Foo") + self.assertEqual(read_record.char_lstripped, "Foo ") + self.assertEqual(read_record.char_rstripped, " Foo") + self.assertEqual(read_record.char_unstripped, " Foo ") + + @with_transaction() + def test_write_strip(self): + "Test write with stripping" + Char = self.Char() + + char, = Char.create([{ + 'char': "Foo", + 'char_lstripped': "Foo", + 'char_rstripped': "Foo", + 'char_unstripped': "Foo", + }]) + + Char.write([char], { + 'char': " Bar ", + 'char_lstripped': " Bar ", + 'char_rstripped': " Bar ", + 'char_unstripped': " Bar ", + }) + read_record = Char(char.id) + self.assertEqual(read_record.char, "Bar") + self.assertEqual(read_record.char_lstripped, "Bar ") + self.assertEqual(read_record.char_rstripped, " Bar") + self.assertEqual(read_record.char_unstripped, " Bar ") + + @with_transaction() + def test_set_strip(self): + "Test set with stripping" + Char = self.Char() + + char = Char() + + char.char = " Foo " + char.char_lstripped = " Foo " + char.char_rstripped = " Foo " + char.char_unstripped = " Foo " + self.assertEqual(char.char, "Foo") + self.assertEqual(char.char_lstripped, "Foo ") + self.assertEqual(char.char_rstripped, " Foo") + self.assertEqual(char.char_unstripped, " Foo ") + class FieldCharTestCase(unittest.TestCase, CommonTestCaseMixin): "Test Field Char" @@ -507,6 +565,48 @@ class FieldCharTestCase(unittest.TestCase, CommonTestCaseMixin): 'char': 'foobar', }) + @with_transaction() + def test_create_strip_with_sql_value(self): + "Test create with stripping with SQL value" + Char = self.Char() + + char, = Char.create([{ + 'char': Literal(" Foo "), + 'char_lstripped': Literal(" Foo "), + 'char_rstripped': Literal(" Foo "), + 'char_unstripped': Literal(" Foo "), + }]) + + read_record = Char(char.id) + self.assertEqual(read_record.char, "Foo") + self.assertEqual(read_record.char_lstripped, "Foo ") + self.assertEqual(read_record.char_rstripped, " Foo") + self.assertEqual(read_record.char_unstripped, " Foo ") + + @with_transaction() + def test_write_strip_with_sql_value(self): + "Test write with stripping with SQL value" + Char = self.Char() + + char, = Char.create([{ + 'char': "Foo", + 'char_lstripped': "Foo", + 'char_rstripped': "Foo", + 'char_unstripped': "Foo", + }]) + + Char.write([char], { + 'char': Literal(" Bar "), + 'char_lstripped': Literal(" Bar "), + 'char_rstripped': Literal(" Bar "), + 'char_unstripped': Literal(" Bar "), + }) + read_record = Char(char.id) + self.assertEqual(read_record.char, "Bar") + self.assertEqual(read_record.char_lstripped, "Bar ") + self.assertEqual(read_record.char_rstripped, " Bar") + self.assertEqual(read_record.char_unstripped, " Bar ") + class FieldCharTranslatedTestCase(unittest.TestCase, CommonTestCaseMixin): "Test Field Char Translated"