From afa640701efbfc5199b72fbe2332e9de9893d62b Mon Sep 17 00:00:00 2001 From: Nigel Gott <nigel@baserow.io> Date: Tue, 23 Mar 2021 13:29:06 +0000 Subject: [PATCH] Use a simpler regex to validate phone number field, add test covering validation and database conversion for phone number field, enable standard filters and sorts. --- .../src/baserow/contrib/database/config.py | 3 +- .../src/baserow/contrib/database/db/schema.py | 33 +- .../contrib/database/fields/field_types.py | 107 ++++- .../contrib/database/fields/handler.py | 25 +- .../baserow/contrib/database/fields/models.py | 4 + .../contrib/database/fields/registries.py | 32 +- .../migrations/0029_phonenumberfield.py | 29 ++ .../src/baserow/contrib/database/models.py | 4 +- .../contrib/database/views/view_filters.py | 11 +- .../api/fields/test_field_views_types.py | 96 +++- .../contrib/database/db/test_db_schema.py | 6 +- .../database/field/test_field_handler.py | 413 +++++++++++++++++- .../database/field/test_field_types.py | 104 ++++- .../database/table/test_table_models.py | 15 +- backend/tests/conftest.py | 20 + backend/tests/fixtures/field.py | 53 ++- changelog.md | 1 + .../001-phone-number-field-validation.md | 107 +++++ web-frontend/modules/core/utils/string.js | 9 + .../row/RowEditFieldPhoneNumber.vue | 27 ++ .../FunctionalGridViewFieldPhoneNumber.vue | 7 + .../grid/fields/GridViewFieldPhoneNumber.vue | 43 ++ web-frontend/modules/database/fieldTypes.js | 71 ++- .../database/mixins/phoneNumberField.js | 26 ++ web-frontend/modules/database/plugin.js | 2 + web-frontend/modules/database/viewFilters.js | 10 +- 26 files changed, 1192 insertions(+), 66 deletions(-) create mode 100644 backend/src/baserow/contrib/database/migrations/0029_phonenumberfield.py create mode 100644 docs/decisions/001-phone-number-field-validation.md create mode 100644 web-frontend/modules/database/components/row/RowEditFieldPhoneNumber.vue create mode 100644 web-frontend/modules/database/components/view/grid/fields/FunctionalGridViewFieldPhoneNumber.vue create mode 100644 web-frontend/modules/database/components/view/grid/fields/GridViewFieldPhoneNumber.vue create mode 100644 web-frontend/modules/database/mixins/phoneNumberField.js diff --git a/backend/src/baserow/contrib/database/config.py b/backend/src/baserow/contrib/database/config.py index 37bb6cc8e..444be6cf7 100644 --- a/backend/src/baserow/contrib/database/config.py +++ b/backend/src/baserow/contrib/database/config.py @@ -47,7 +47,7 @@ class DatabaseConfig(AppConfig): from .fields.field_types import ( TextFieldType, LongTextFieldType, URLFieldType, NumberFieldType, BooleanFieldType, DateFieldType, LinkRowFieldType, EmailFieldType, - FileFieldType, SingleSelectFieldType + FileFieldType, SingleSelectFieldType, PhoneNumberFieldType ) field_type_registry.register(TextFieldType()) field_type_registry.register(LongTextFieldType()) @@ -59,6 +59,7 @@ class DatabaseConfig(AppConfig): field_type_registry.register(LinkRowFieldType()) field_type_registry.register(FileFieldType()) field_type_registry.register(SingleSelectFieldType()) + field_type_registry.register(PhoneNumberFieldType()) from .fields.field_converters import LinkRowFieldConverter, FileFieldConverter field_converter_registry.register(LinkRowFieldConverter()) diff --git a/backend/src/baserow/contrib/database/db/schema.py b/backend/src/baserow/contrib/database/db/schema.py index 440490dff..23cd79c2a 100644 --- a/backend/src/baserow/contrib/database/db/schema.py +++ b/backend/src/baserow/contrib/database/db/schema.py @@ -10,9 +10,9 @@ class PostgresqlLenientDatabaseSchemaEditor: format. If the casting still fails the value will be set to null. """ - sql_alter_column_type = "ALTER COLUMN %(column)s TYPE %(type)s " \ - "USING pg_temp.try_cast(%(column)s::text)" - sql_drop_try_cast = "DROP FUNCTION IF EXISTS pg_temp.try_cast(text, int)" + sql_alter_column_type = 'ALTER COLUMN %(column)s TYPE %(type)s ' \ + 'USING pg_temp.try_cast(%(column)s::text)' + sql_drop_try_cast = 'DROP FUNCTION IF EXISTS pg_temp.try_cast(text, int)' sql_create_try_cast = """ create or replace function pg_temp.try_cast( p_in text, @@ -35,16 +35,20 @@ class PostgresqlLenientDatabaseSchemaEditor: """ def __init__(self, *args, alter_column_prepare_old_value='', - alter_column_prepare_new_value=''): + alter_column_prepare_new_value='', + force_alter_column=False): self.alter_column_prepare_old_value = alter_column_prepare_old_value self.alter_column_prepare_new_value = alter_column_prepare_new_value + self.force_alter_column = force_alter_column super().__init__(*args) def _alter_field(self, model, old_field, new_field, old_type, new_type, old_db_params, new_db_params, strict=False): + if self.force_alter_column: + old_type = f'{old_type}_forced' + if old_type != new_type: variables = {} - if isinstance(self.alter_column_prepare_old_value, tuple): alter_column_prepare_old_value, v = self.alter_column_prepare_old_value variables = {**variables, **v} @@ -57,12 +61,13 @@ class PostgresqlLenientDatabaseSchemaEditor: else: alter_column_prepare_new_value = self.alter_column_prepare_new_value + quoted_column_name = self.quote_name(new_field.column) self.execute(self.sql_drop_try_cast) self.execute(self.sql_create_try_cast % { - "column": self.quote_name(new_field.column), - "type": new_type, - "alter_column_prepare_old_value": alter_column_prepare_old_value, - "alter_column_prepare_new_value": alter_column_prepare_new_value + 'column': quoted_column_name, + 'type': new_type, + 'alter_column_prepare_old_value': alter_column_prepare_old_value, + 'alter_column_prepare_new_value': alter_column_prepare_new_value }, variables) return super()._alter_field(model, old_field, new_field, old_type, new_type, @@ -71,7 +76,8 @@ class PostgresqlLenientDatabaseSchemaEditor: @contextlib.contextmanager def lenient_schema_editor(connection, alter_column_prepare_old_value=None, - alter_column_prepare_new_value=None): + alter_column_prepare_new_value=None, + force_alter_column=False): """ A contextual function that yields a modified version of the connection's schema editor. This temporary version is more lenient then the regular editor. Normally @@ -89,6 +95,9 @@ def lenient_schema_editor(connection, alter_column_prepare_old_value=None, :param alter_column_prepare_new_value: Optionally a query statement converting the `p_in` text value to the new type. :type alter_column_prepare_new_value: None or str + :param force_alter_column: When true forces the schema editor to run an alter + column statement using the previous two alter_column_prepare parameters. + :type force_alter_column: bool :raises ValueError: When the provided connection is not supported. For now only `postgresql` is supported. """ @@ -109,7 +118,9 @@ def lenient_schema_editor(connection, alter_column_prepare_old_value=None, connection.SchemaEditorClass = schema_editor_class - kwargs = {} + kwargs = { + 'force_alter_column': force_alter_column + } if alter_column_prepare_old_value: kwargs['alter_column_prepare_old_value'] = alter_column_prepare_old_value diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py index 9c7c538bd..0ed1a4788 100644 --- a/backend/src/baserow/contrib/database/fields/field_types.py +++ b/backend/src/baserow/contrib/database/fields/field_types.py @@ -6,7 +6,7 @@ from dateutil import parser from dateutil.parser import ParserError from django.contrib.postgres.fields import JSONField from django.core.exceptions import ValidationError -from django.core.validators import URLValidator, EmailValidator +from django.core.validators import URLValidator, EmailValidator, RegexValidator from django.db import models from django.db.models import Case, When, Q, F, Func, Value, CharField from django.db.models.expressions import RawSQL @@ -35,7 +35,7 @@ from .models import ( NUMBER_TYPE_INTEGER, NUMBER_TYPE_DECIMAL, TextField, LongTextField, URLField, NumberField, BooleanField, DateField, LinkRowField, EmailField, FileField, - SingleSelectField, SelectOption + SingleSelectField, SelectOption, PhoneNumberField ) from .registries import FieldType, field_type_registry @@ -196,25 +196,8 @@ class NumberFieldType(FieldType): return super().get_alter_column_prepare_new_value(connection, from_field, to_field) - def after_update(self, from_field, to_field, from_model, to_model, user, connection, - altered_column, before): - """ - The allowing of negative values isn't stored in the database field type. If - the type hasn't changed, but the allowing of negative values has it means that - the column data hasn't been converted to positive values yet. We need to do - this here. All the negatives values are set to 0. - """ - - if ( - not altered_column - and not to_field.number_negative - and from_field.number_negative - ): - to_model.objects.filter(**{ - f'field_{to_field.id}__lt': 0 - }).update(**{ - f'field_{to_field.id}': 0 - }) + def force_same_type_alter_column(self, from_field, to_field): + return not to_field.number_negative and from_field.number_negative def contains_query(self, *args): return contains_filter(*args) @@ -1035,3 +1018,85 @@ class SingleSelectFieldType(FieldType): annotation={f"select_option_value_{field_name}": query}, q={f'select_option_value_{field_name}__icontains': value} ) + + +class PhoneNumberFieldType(FieldType): + """ + A simple wrapper around a TextField which ensures any entered data is a + simple phone number. + + See `docs/decisions/001-phone-number-field-validation.md` for context + as to why the phone number validation was implemented using a simple regex. + """ + + type = 'phone_number' + model_class = PhoneNumberField + + MAX_PHONE_NUMBER_LENGTH = 100 + """ + According to the E.164 (https://en.wikipedia.org/wiki/E.164) standard for + international numbers the max length of an E.164 number without formatting is 15 + characters. However we allow users to store formatting characters, spaces and + expect them to be entering numbers not in the E.164 standard but instead a + wide range of local standards which might support longer numbers. + This is why we have picked a very generous 100 character length to support heavily + formatted local numbers. + """ + + PHONE_NUMBER_REGEX = rf'^[0-9NnXx,+._*()#=;/ -]{{1,{MAX_PHONE_NUMBER_LENGTH}}}$' + """ + Allow common punctuation used in phone numbers and spaces to allow formatting, + but otherwise don't allow text as the phone number should work as a link on mobile + devices. + Duplicated in the frontend code at, please keep in sync: + web-frontend/modules/core/utils/string.js#isSimplePhoneNumber + """ + + simple_phone_number_validator = RegexValidator( + regex=PHONE_NUMBER_REGEX) + + def prepare_value_for_db(self, instance, value): + if value == '' or value is None: + return '' + self.simple_phone_number_validator(value) + + return value + + def get_serializer_field(self, instance, **kwargs): + return serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + validators=[self.simple_phone_number_validator], + max_length=self.MAX_PHONE_NUMBER_LENGTH, + **kwargs + ) + + def get_model_field(self, instance, **kwargs): + return models.CharField( + default='', + blank=True, + null=True, + max_length=self.MAX_PHONE_NUMBER_LENGTH, + validators=[ + self.simple_phone_number_validator], + **kwargs) + + def random_value(self, instance, fake, cache): + return fake.phone_number() + + def get_alter_column_prepare_new_value(self, connection, from_field, to_field): + if connection.vendor == 'postgresql': + return f'''p_in = ( + case + when p_in::text ~* '{self.PHONE_NUMBER_REGEX}' + then p_in::text + else '' + end + );''' + + return super().get_alter_column_prepare_new_value(connection, from_field, + to_field) + + def contains_query(self, *args): + return contains_filter(*args) diff --git a/backend/src/baserow/contrib/database/fields/handler.py b/backend/src/baserow/contrib/database/fields/handler.py index 366e5665d..419afc1df 100644 --- a/backend/src/baserow/contrib/database/fields/handler.py +++ b/backend/src/baserow/contrib/database/fields/handler.py @@ -1,23 +1,21 @@ import logging from copy import deepcopy +from django.conf import settings from django.db import connections from django.db.utils import ProgrammingError, DataError -from django.conf import settings -from baserow.core.utils import extract_allowed, set_allowed_attrs from baserow.contrib.database.db.schema import lenient_schema_editor from baserow.contrib.database.views.handler import ViewHandler - +from baserow.core.utils import extract_allowed, set_allowed_attrs from .exceptions import ( PrimaryFieldAlreadyExists, CannotDeletePrimaryField, CannotChangeFieldType, FieldDoesNotExist, IncompatiblePrimaryFieldTypeError ) -from .registries import field_type_registry, field_converter_registry from .models import Field, SelectOption +from .registries import field_type_registry, field_converter_registry from .signals import field_created, field_updated, field_deleted - logger = logging.getLogger(__name__) @@ -159,7 +157,8 @@ class FieldHandler: # If the provided field type does not match with the current one we need to # migrate the field to the new type. Because the type has changed we also need # to remove all view filters. - if new_type_name and field_type.type != new_type_name: + baserow_field_type_changed = new_type_name and field_type.type != new_type_name + if baserow_field_type_changed: field_type = field_type_registry.get(new_type_name) if field.primary and not field_type.can_be_primary_field: @@ -217,6 +216,17 @@ class FieldHandler: connection ) else: + if baserow_field_type_changed: + # If the baserow type has changed we always want to force run any alter + # column SQL as otherwise it might not run if the two baserow fields + # share the same underlying database column type. + force_alter_column = True + else: + force_alter_column = field_type.force_same_type_alter_column( + old_field, + field + ) + # If no field converter is found we are going to alter the field using the # the lenient schema editor. with lenient_schema_editor( @@ -225,7 +235,8 @@ class FieldHandler: connection, old_field, field), field_type.get_alter_column_prepare_new_value( connection, old_field, field - ) + ), + force_alter_column ) as schema_editor: try: schema_editor.alter_field(from_model, from_model_field, diff --git a/backend/src/baserow/contrib/database/fields/models.py b/backend/src/baserow/contrib/database/fields/models.py index c232281e8..924a65a18 100644 --- a/backend/src/baserow/contrib/database/fields/models.py +++ b/backend/src/baserow/contrib/database/fields/models.py @@ -304,3 +304,7 @@ class FileField(Field): class SingleSelectField(Field): pass + + +class PhoneNumberField(Field): + pass diff --git a/backend/src/baserow/contrib/database/fields/registries.py b/backend/src/baserow/contrib/database/fields/registries.py index b2a109756..d524661a7 100644 --- a/backend/src/baserow/contrib/database/fields/registries.py +++ b/backend/src/baserow/contrib/database/fields/registries.py @@ -215,6 +215,9 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin, """ Can return an SQL statement to convert the `p_in` variable to a readable text format for the new field. + This SQL will not be run when converting between two fields of the same + baserow type which share the same underlying database column type. + If you require this then implement force_same_type_alter_column. Example: return "p_in = lower(p_in);" @@ -236,8 +239,11 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin, """ Can return a SQL statement to convert the `p_in` variable from text to a desired format for the new field. + This SQL will not be run when converting between two fields of the same + baserow type which share the same underlying database column type. + If you require this then implement force_same_type_alter_column. - Example when a string is converted to a number, to statement could be: + Example: when a string is converted to a number, to statement could be: `REGEXP_REPLACE(p_in, '[^0-9]', '', 'g')` which would remove all non numeric characters. The p_in variable is the old value as a string. @@ -410,6 +416,30 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin, return None + def force_same_type_alter_column(self, from_field, to_field): + """ + Defines whether the sql provided by the get_alter_column_prepare_{old,new}_value + hooks should be forced to run when converting between two fields of this field + type which have the same database column type. + You only need to implement this when when you have validation and/or data + manipulation running as part of your alter_column_prepare SQL which must be + run even when from_field and to_field are the same Baserow field type and sql + column type. If your field has the same baserow type but will convert into + different sql column types then the alter sql will be run automatically and you + do not need to use this override. + + :param from_field: The old field instance. It is not recommended to call the + save function as this will undo part of the changes that have been made. + This is just for comparing values. + :type from_field: Field + :param to_field: The updated field instance. + :type: to_field: Field + :return: Whether the alter column sql should be forced to run. + :rtype: bool + """ + + return False + class FieldTypeRegistry(APIUrlsRegistryMixin, CustomFieldsRegistryMixin, ModelRegistryMixin, Registry): diff --git a/backend/src/baserow/contrib/database/migrations/0029_phonenumberfield.py b/backend/src/baserow/contrib/database/migrations/0029_phonenumberfield.py new file mode 100644 index 000000000..aea3d0fc4 --- /dev/null +++ b/backend/src/baserow/contrib/database/migrations/0029_phonenumberfield.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.11 on 2021-03-15 09:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ('database', '0028_fix_negative_date'), + ] + + operations = [ + migrations.CreateModel( + name='PhoneNumberField', + fields=[ + ('field_ptr', models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, primary_key=True, + serialize=False, + to='database.Field' + )), + ], + options={ + 'abstract': False, + }, + bases=('database.field',), + ), + ] diff --git a/backend/src/baserow/contrib/database/models.py b/backend/src/baserow/contrib/database/models.py index a28b5d8a8..98b8d40bb 100644 --- a/backend/src/baserow/contrib/database/models.py +++ b/backend/src/baserow/contrib/database/models.py @@ -4,7 +4,7 @@ from .table.models import Table from .views.models import View, GridView, GridViewFieldOptions, ViewFilter from .fields.models import ( Field, TextField, NumberField, LongTextField, BooleanField, DateField, LinkRowField, - URLField, EmailField + URLField, EmailField, PhoneNumberField ) from .tokens.models import Token, TokenPermission @@ -13,7 +13,7 @@ __all__ = [ 'Table', 'View', 'GridView', 'GridViewFieldOptions', 'ViewFilter', 'Field', 'TextField', 'NumberField', 'LongTextField', 'BooleanField', 'DateField', - 'LinkRowField', 'URLField', 'EmailField', + 'LinkRowField', 'URLField', 'EmailField', 'PhoneNumberField', 'Token', 'TokenPermission' ] diff --git a/backend/src/baserow/contrib/database/views/view_filters.py b/backend/src/baserow/contrib/database/views/view_filters.py index 49cb8d99d..1758f36e4 100644 --- a/backend/src/baserow/contrib/database/views/view_filters.py +++ b/backend/src/baserow/contrib/database/views/view_filters.py @@ -11,7 +11,7 @@ from pytz import timezone from baserow.contrib.database.fields.field_types import ( TextFieldType, LongTextFieldType, URLFieldType, NumberFieldType, DateFieldType, LinkRowFieldType, BooleanFieldType, EmailFieldType, FileFieldType, - SingleSelectFieldType + SingleSelectFieldType, PhoneNumberFieldType ) from .registries import ViewFilterType from baserow.contrib.database.fields.field_filters import contains_filter, \ @@ -38,7 +38,8 @@ class EqualViewFilterType(ViewFilterType): URLFieldType.type, NumberFieldType.type, BooleanFieldType.type, - EmailFieldType.type + EmailFieldType.type, + PhoneNumberFieldType.type ] def get_filter(self, field_name, value, model_field, field): @@ -89,7 +90,8 @@ class ContainsViewFilterType(ViewFilterType): TextFieldType.type, LongTextFieldType.type, URLFieldType.type, - EmailFieldType.type + EmailFieldType.type, + PhoneNumberFieldType.type ] def get_filter(self, *args): @@ -285,7 +287,8 @@ class EmptyViewFilterType(ViewFilterType): LinkRowFieldType.type, EmailFieldType.type, FileFieldType.type, - SingleSelectFieldType.type + SingleSelectFieldType.type, + PhoneNumberFieldType.type ] def get_filter(self, field_name, value, model_field, field): diff --git a/backend/tests/baserow/contrib/database/api/fields/test_field_views_types.py b/backend/tests/baserow/contrib/database/api/fields/test_field_views_types.py index 5737d6c47..c8a32ef93 100644 --- a/backend/tests/baserow/contrib/database/api/fields/test_field_views_types.py +++ b/backend/tests/baserow/contrib/database/api/fields/test_field_views_types.py @@ -10,7 +10,8 @@ from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_400_BAD from django.shortcuts import reverse from baserow.contrib.database.fields.models import ( - LongTextField, URLField, DateField, EmailField, FileField, NumberField + LongTextField, URLField, DateField, EmailField, FileField, NumberField, + PhoneNumberField ) @@ -842,3 +843,96 @@ def test_number_field_type(api_client, data_fixture): response_json['detail'][f'field_{positive_int_field_id}'][0]['code'] == 'max_digits' ) + + +@pytest.mark.django_db +def test_phone_number_field_type(api_client, data_fixture): + user, token = data_fixture.create_user_and_token( + email='test@test.nl', password='password', first_name='Test1') + table = data_fixture.create_database_table(user=user) + + response = api_client.post( + reverse('api:database:fields:list', kwargs={'table_id': table.id}), + {'name': 'phone', 'type': 'phone_number'}, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert response_json['type'] == 'phone_number' + assert PhoneNumberField.objects.all().count() == 1 + field_id = response_json['id'] + + response = api_client.patch( + reverse('api:database:fields:item', kwargs={'field_id': field_id}), + {'name': 'Phone'}, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + assert response.status_code == HTTP_200_OK + + expected_phone_number = '+44761198672' + + response = api_client.post( + reverse('api:database:rows:list', kwargs={'table_id': table.id}), + { + f'field_{field_id}': expected_phone_number + }, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert response_json[f'field_{field_id}'] == expected_phone_number + + model = table.get_model(attribute_names=True) + row = model.objects.all().last() + assert row.phone == expected_phone_number + + response = api_client.post( + reverse('api:database:rows:list', kwargs={'table_id': table.id}), + { + f'field_{field_id}': '' + }, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert response_json[f'field_{field_id}'] == '' + + row = model.objects.all().last() + assert row.phone == '' + + response = api_client.post( + reverse('api:database:rows:list', kwargs={'table_id': table.id}), + { + f'field_{field_id}': None + }, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert response_json[f'field_{field_id}'] == '' + + row = model.objects.all().last() + assert row.phone == '' + + response = api_client.post( + reverse('api:database:rows:list', kwargs={'table_id': table.id}), + {}, + format='json', + HTTP_AUTHORIZATION=f'JWT {token}' + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert response_json[f'field_{field_id}'] == '' + + row = model.objects.all().last() + assert row.phone == '' + + email = reverse('api:database:fields:item', kwargs={'field_id': field_id}) + response = api_client.delete(email, HTTP_AUTHORIZATION=f'JWT {token}') + assert response.status_code == HTTP_204_NO_CONTENT + assert PhoneNumberField.objects.all().count() == 0 diff --git a/backend/tests/baserow/contrib/database/db/test_db_schema.py b/backend/tests/baserow/contrib/database/db/test_db_schema.py index 7e8f68d3c..4b5a394d6 100644 --- a/backend/tests/baserow/contrib/database/db/test_db_schema.py +++ b/backend/tests/baserow/contrib/database/db/test_db_schema.py @@ -1,5 +1,4 @@ import pytest - from django.db import connection from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django.db.backends.dummy.base import DatabaseWrapper as DummyDatabaseWrapper @@ -26,6 +25,7 @@ def test_lenient_schema_editor(): assert isinstance(schema_editor, BaseDatabaseSchemaEditor) assert schema_editor.alter_column_prepare_old_value == '' assert schema_editor.alter_column_prepare_new_value == '' + assert not schema_editor.force_alter_column assert connection.SchemaEditorClass != PostgresqlDatabaseSchemaEditor assert connection.SchemaEditorClass == PostgresqlDatabaseSchemaEditor @@ -33,7 +33,8 @@ def test_lenient_schema_editor(): with lenient_schema_editor( connection, "p_in = REGEXP_REPLACE(p_in, '', 'test', 'g');", - "p_in = REGEXP_REPLACE(p_in, 'test', '', 'g');" + "p_in = REGEXP_REPLACE(p_in, 'test', '', 'g');", + True ) as schema_editor: assert schema_editor.alter_column_prepare_old_value == ( "p_in = REGEXP_REPLACE(p_in, '', 'test', 'g');" @@ -41,3 +42,4 @@ def test_lenient_schema_editor(): assert schema_editor.alter_column_prepare_new_value == ( "p_in = REGEXP_REPLACE(p_in, 'test', '', 'g');" ) + assert schema_editor.force_alter_column diff --git a/backend/tests/baserow/contrib/database/field/test_field_handler.py b/backend/tests/baserow/contrib/database/field/test_field_handler.py index 0e3166557..549398686 100644 --- a/backend/tests/baserow/contrib/database/field/test_field_handler.py +++ b/backend/tests/baserow/contrib/database/field/test_field_handler.py @@ -1,18 +1,126 @@ -import pytest +import itertools +from datetime import date from decimal import Decimal from unittest.mock import patch -from baserow.core.exceptions import UserNotInGroupError -from baserow.contrib.database.fields.handler import FieldHandler -from baserow.contrib.database.fields.models import ( - Field, TextField, NumberField, BooleanField, SelectOption -) -from baserow.contrib.database.fields.field_types import TextFieldType -from baserow.contrib.database.fields.registries import field_type_registry +import pytest +from django.db import models +from faker import Faker + from baserow.contrib.database.fields.exceptions import ( FieldTypeDoesNotExist, PrimaryFieldAlreadyExists, CannotDeletePrimaryField, FieldDoesNotExist, IncompatiblePrimaryFieldTypeError, CannotChangeFieldType ) +from baserow.contrib.database.fields.field_types import TextFieldType, LongTextFieldType +from baserow.contrib.database.fields.handler import FieldHandler +from baserow.contrib.database.fields.models import ( + Field, TextField, NumberField, BooleanField, SelectOption, LongTextField, + NUMBER_TYPE_CHOICES +) +from baserow.contrib.database.fields.registries import field_type_registry +from baserow.contrib.database.rows.handler import RowHandler +from baserow.core.exceptions import UserNotInGroupError + + +def dict_to_pairs(field_type_kwargs): + pairs_dict = {} + for name, options in field_type_kwargs.items(): + pairs_dict[name] = [] + if not isinstance(options, list): + options = [options] + for option in options: + pairs_dict[name].append((name, option)) + return pairs_dict + + +def construct_all_possible_kwargs(field_type_kwargs): + pairs_dict = dict_to_pairs(field_type_kwargs) + args = [dict(pairwise_args) for pairwise_args in itertools.product( + *pairs_dict.values())] + + return args + + +# You must add --runslow to pytest to run this test, you can do this in intellij by +# editing the run config for this test and adding --runslow to additional args. +@pytest.mark.django_db +@pytest.mark.slow +def test_can_convert_between_all_fields(data_fixture): + """ + A nuclear option test turned off by default to help verify changes made to + field conversions work in every possible conversion scenario. This test checks + is possible to convert from every possible field to every other possible field + including converting to themselves. It only checks that the conversion does not + raise any exceptions. + """ + + user = data_fixture.create_user() + database = data_fixture.create_database_application(user=user) + table = data_fixture.create_database_table(database=database, user=user) + link_table = data_fixture.create_database_table(database=database, user=user) + handler = FieldHandler() + row_handler = RowHandler() + fake = Faker() + + model = table.get_model() + cache = {} + # Make a blank row to test empty field conversion also. + model.objects.create(**{}) + second_row_with_values = model.objects.create(**{}) + + # Some baserow field types have multiple different 'modes' which result in + # different conversion behaviour or entirely different database columns being + # created. Here the kwargs which control these modes are enumerated so we can then + # generate every possible type of conversion. + extra_kwargs_for_type = { + 'date': { + 'date_include_time': [True, False], + }, + 'number': { + 'number_type': [number_type for number_type, _ in NUMBER_TYPE_CHOICES], + 'number_negative': [True, False], + }, + 'link_row': { + 'link_row_table': link_table + } + } + + all_possible_kwargs_per_type = {} + for field_type_name in field_type_registry.get_types(): + extra_kwargs = extra_kwargs_for_type.get(field_type_name, {}) + all_possible_kwargs = construct_all_possible_kwargs(extra_kwargs) + all_possible_kwargs_per_type[field_type_name] = all_possible_kwargs + + i = 1 + for field_type_name, all_possible_kwargs in all_possible_kwargs_per_type.items(): + for kwargs in all_possible_kwargs: + for inner_field_type_name in field_type_registry.get_types(): + for inner_kwargs in all_possible_kwargs_per_type[inner_field_type_name]: + field_type = field_type_registry.get(field_type_name) + field_name = f'field_{i}' + from_field = handler.create_field( + user=user, table=table, type_name=field_type_name, + name=field_name, + **kwargs + ) + random_value = field_type.random_value( + from_field, + fake, + cache + ) + if isinstance(random_value, date): + # Faker produces subtypes of date / datetime which baserow + # does not want, instead just convert to str. + random_value = str(random_value) + row_handler.update_row(user=user, table=table, + row_id=second_row_with_values.id, + values={ + f'field_{from_field.id}': random_value + }) + handler.update_field(user=user, field=from_field, + new_type_name=inner_field_type_name, + **inner_kwargs) + i = i + 1 @pytest.mark.django_db @@ -269,6 +377,295 @@ def test_update_field_failing(data_fixture): assert TextField.objects.all().count() == 1 +@pytest.mark.django_db +def test_update_field_when_underlying_sql_type_doesnt_change(data_fixture): + class AlwaysLowercaseTextField(TextFieldType): + type = 'lowercase_text' + model_class = LongTextField + + def get_alter_column_prepare_new_value(self, connection, from_field, to_field): + return '''p_in = (lower(p_in));''' + + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + existing_text_field = data_fixture.create_text_field(table=table, order=1) + + model = table.get_model() + + field_name = f'field_{existing_text_field.id}' + row = model.objects.create(**{ + field_name: 'Test', + }) + + handler = FieldHandler() + + with patch.dict( + field_type_registry.registry, + {'lowercase_text': AlwaysLowercaseTextField()} + ): + handler.update_field(user=user, + field=existing_text_field, + new_type_name='lowercase_text') + + row.refresh_from_db() + assert getattr(row, field_name) == 'test' + assert Field.objects.all().count() == 1 + assert TextField.objects.all().count() == 0 + assert LongTextField.objects.all().count() == 1 + + +@pytest.mark.django_db +def test_field_which_changes_its_underlying_type_will_have_alter_sql_run(data_fixture): + class ReversingTextFieldUsingBothVarCharAndTextSqlTypes(TextFieldType): + + def get_alter_column_prepare_new_value(self, connection, from_field, to_field): + return '''p_in = (reverse(p_in));''' + + def get_model_field(self, instance, **kwargs): + kwargs['null'] = True + kwargs['blank'] = True + if instance.text_default == 'use_other_sql_type': + return models.TextField(**kwargs) + else: + return models.CharField(**kwargs) + + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + existing_text_field = data_fixture.create_text_field(table=table, order=1) + + model = table.get_model() + + field_name = f'field_{existing_text_field.id}' + row = model.objects.create(**{ + field_name: 'Test', + }) + + handler = FieldHandler() + + with patch.dict( + field_type_registry.registry, + {'text': ReversingTextFieldUsingBothVarCharAndTextSqlTypes()} + ): + # Update to the same baserow type, but due to this fields implementation of + # get_model_field this will alter the underlying database column from type + # of varchar to text, which should make our reversing alter sql run. + handler.update_field(user=user, + field=existing_text_field, + new_type_name='text', + text_default='use_other_sql_type') + + row.refresh_from_db() + assert getattr(row, field_name) == 'tseT' + assert Field.objects.all().count() == 1 + assert TextField.objects.all().count() == 1 + + +@pytest.mark.django_db +def test_just_changing_a_fields_name_will_not_run_alter_sql(data_fixture): + class AlwaysReverseOnUpdateField(TextFieldType): + def get_alter_column_prepare_new_value(self, connection, from_field, to_field): + return '''p_in = (reverse(p_in));''' + + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + existing_text_field = data_fixture.create_text_field(table=table, order=1) + + model = table.get_model() + + field_name = f'field_{existing_text_field.id}' + row = model.objects.create(**{ + field_name: 'Test', + }) + + handler = FieldHandler() + + with patch.dict( + field_type_registry.registry, + {'text': AlwaysReverseOnUpdateField()} + ): + handler.update_field(user=user, field=existing_text_field, + new_type_name='text', name='new_name') + + row.refresh_from_db() + # The field has not been reversed as just the name changed! + assert getattr(row, field_name) == 'Test' + assert Field.objects.all().count() == 1 + assert TextField.objects.all().count() == 1 + + +@pytest.mark.django_db +def test_when_field_type_forces_same_type_alter_fields_alter_sql_is_run(data_fixture): + class SameTypeAlwaysReverseOnUpdateField(TextFieldType): + def get_alter_column_prepare_new_value(self, connection, from_field, to_field): + return '''p_in = (reverse(p_in));''' + + def force_same_type_alter_column(self, from_field, to_field): + return True + + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + existing_text_field = data_fixture.create_text_field(table=table, order=1) + + model = table.get_model() + + field_name = f'field_{existing_text_field.id}' + row = model.objects.create(**{ + field_name: 'Test', + }) + + handler = FieldHandler() + + with patch.dict( + field_type_registry.registry, + {'text': SameTypeAlwaysReverseOnUpdateField()} + ): + handler.update_field(user=user, field=existing_text_field, + new_type_name='text', name='new_name') + + row.refresh_from_db() + # The alter sql has been run due to the force override + assert getattr(row, field_name) == 'tseT' + assert Field.objects.all().count() == 1 + assert TextField.objects.all().count() == 1 + + +@pytest.mark.django_db +def test_update_field_with_type_error_on_conversion_should_null_field(data_fixture): + class AlwaysThrowsSqlExceptionOnConversionField(TextFieldType): + type = 'throws_field' + model_class = LongTextField + + def get_alter_column_prepare_new_value(self, connection, from_field, to_field): + return '''p_in = (lower(p_in::numeric::text));''' + + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + existing_text_field = data_fixture.create_text_field(table=table, order=1) + + model = table.get_model() + + field_name = f'field_{existing_text_field.id}' + row = model.objects.create(**{ + field_name: 'Test', + }) + + handler = FieldHandler() + + with patch.dict( + field_type_registry.registry, + {'throws_field': AlwaysThrowsSqlExceptionOnConversionField()} + ): + handler.update_field(user=user, + field=existing_text_field, + new_type_name='throws_field') + + row.refresh_from_db() + assert getattr(row, field_name) is None + assert Field.objects.all().count() == 1 + assert TextField.objects.all().count() == 0 + assert LongTextField.objects.all().count() == 1 + + +@pytest.mark.django_db +def test_update_field_when_underlying_sql_type_doesnt_change_with_vars(data_fixture): + class ReversesWhenConvertsAwayTextField(LongTextFieldType): + type = 'reserves_text' + model_class = LongTextField + + def get_alter_column_prepare_old_value(self, connection, from_field, to_field): + return '''p_in = concat(reverse(p_in), %(some_variable)s);''', { + "some_variable": "_POST_FIX" + } + + class AlwaysLowercaseTextField(TextFieldType): + type = 'lowercase_text' + model_class = LongTextField + + def get_alter_column_prepare_new_value(self, connection, from_field, to_field): + return '''p_in = concat(%(other_variable)s, lower(p_in));''', { + "other_variable": "pre_fix_" + } + + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + existing_field_with_old_value_prep = data_fixture.create_long_text_field( + table=table) + + model = table.get_model() + + field_name = f'field_{existing_field_with_old_value_prep.id}' + row = model.objects.create(**{ + field_name: 'Test', + }) + + handler = FieldHandler() + + with patch.dict( + field_type_registry.registry, + { + 'lowercase_text': AlwaysLowercaseTextField(), + 'long_text': ReversesWhenConvertsAwayTextField() + } + ): + handler.update_field(user=user, + field=existing_field_with_old_value_prep, + new_type_name='lowercase_text') + + row.refresh_from_db() + assert getattr(row, field_name) == 'pre_fix_tset_post_fix' + assert Field.objects.all().count() == 1 + assert TextField.objects.all().count() == 0 + assert LongTextField.objects.all().count() == 1 + + +@pytest.mark.django_db +def test_update_field_when_underlying_sql_type_doesnt_change_old_prep(data_fixture): + class ReversesWhenConvertsAwayTextField(LongTextFieldType): + type = 'reserves_text' + model_class = LongTextField + + def get_alter_column_prepare_old_value(self, connection, from_field, to_field): + return '''p_in = (reverse(p_in));''' + + class AlwaysLowercaseTextField(TextFieldType): + type = 'lowercase_text' + model_class = LongTextField + + def get_alter_column_prepare_new_value(self, connection, from_field, to_field): + return '''p_in = (lower(p_in));''' + + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + existing_field_with_old_value_prep = data_fixture.create_long_text_field( + table=table) + + model = table.get_model() + + field_name = f'field_{existing_field_with_old_value_prep.id}' + row = model.objects.create(**{ + field_name: 'Test', + }) + + handler = FieldHandler() + + with patch.dict( + field_type_registry.registry, + { + 'lowercase_text': AlwaysLowercaseTextField(), + 'long_text': ReversesWhenConvertsAwayTextField() + } + ): + handler.update_field(user=user, + field=existing_field_with_old_value_prep, + new_type_name='lowercase_text') + + row.refresh_from_db() + assert getattr(row, field_name) == 'tset' + assert Field.objects.all().count() == 1 + assert TextField.objects.all().count() == 0 + assert LongTextField.objects.all().count() == 1 + + @pytest.mark.django_db @patch('baserow.contrib.database.fields.signals.field_deleted.send') def test_delete_field(send_mock, data_fixture): diff --git a/backend/tests/baserow/contrib/database/field/test_field_types.py b/backend/tests/baserow/contrib/database/field/test_field_types.py index 1167f9979..89db5e150 100644 --- a/backend/tests/baserow/contrib/database/field/test_field_types.py +++ b/backend/tests/baserow/contrib/database/field/test_field_types.py @@ -1,15 +1,18 @@ import pytest import json + +from django.test.utils import override_settings from faker import Faker from decimal import Decimal from django.core.exceptions import ValidationError +from baserow.contrib.database.fields.field_types import PhoneNumberFieldType from baserow.core.user_files.exceptions import ( InvalidUserFileNameError, UserFileDoesNotExist ) from baserow.contrib.database.fields.models import ( - LongTextField, URLField, EmailField, FileField + LongTextField, URLField, EmailField, FileField, PhoneNumberField ) from baserow.contrib.database.fields.handler import FieldHandler from baserow.contrib.database.rows.handler import RowHandler @@ -511,3 +514,102 @@ def test_file_field_type(data_fixture): assert results[0].text is None assert results[1].text is None assert results[2].text is None + + +@pytest.mark.django_db +@override_settings(debug=True) +def test_phone_number_field_type(data_fixture): + user = data_fixture.create_user() + table = data_fixture.create_database_table(user=user) + data_fixture.create_database_table(user=user, database=table.database) + + field_handler = FieldHandler() + row_handler = RowHandler() + + text_field = field_handler.create_field(user=user, table=table, + order=1, + type_name='text', + name='name') + phone_number_field = field_handler.create_field(user=user, table=table, + type_name='phone_number', + name='phonenumber') + email_field = field_handler.create_field(user=user, table=table, + type_name='email', + name='email') + number_field = data_fixture.create_number_field(table=table, order=1, + number_negative=True, name="number") + + assert len(PhoneNumberField.objects.all()) == 1 + model = table.get_model(attribute_names=True) + + with pytest.raises(ValidationError): + row_handler.create_row(user=user, table=table, values={ + 'phonenumber': 'invalid phone number' + }, model=model) + + with pytest.raises(ValidationError): + row_handler.create_row(user=user, table=table, values={ + 'phonenumber': 'Phone: 2312321 2349432 ' + }, model=model) + with pytest.raises(ValidationError): + row_handler.create_row(user=user, table=table, values={ + 'phonenumber': '1' * (PhoneNumberFieldType.MAX_PHONE_NUMBER_LENGTH+1) + }, model=model) + + max_length_phone_number = '1' * PhoneNumberFieldType.MAX_PHONE_NUMBER_LENGTH + row_handler.create_row(user=user, table=table, values={ + 'name': '+45(1424) 322314 324234', + 'phonenumber': max_length_phone_number, + 'number': 1234534532, + 'email': 'a_valid_email_to_be_blanked_after_conversion@email.com' + }, model=model) + row_handler.create_row(user=user, table=table, values={ + 'name': 'some text which should be blanked out after conversion', + 'phonenumber': '1234567890 NnXx,+._*()#=;/ -', + 'number': 0 + }, model=model) + row_handler.create_row(user=user, table=table, values={ + 'name': max_length_phone_number, + 'phonenumber': '', + 'number': -10230450, + }, model=model) + row_handler.create_row(user=user, table=table, values={ + 'phonenumber': None, + 'name': '1' * (PhoneNumberFieldType.MAX_PHONE_NUMBER_LENGTH+1) + + }, model=model) + row_handler.create_row(user=user, table=table, values={}, model=model) + + # No actual database type change occurs here as a phone number field is also a text + # field. Instead the after_update hook is being used to clear out invalid + # phone numbers. + field_handler.update_field(user=user, field=text_field, + new_type_name='phone_number') + + field_handler.update_field(user=user, field=number_field, + new_type_name='phone_number') + field_handler.update_field(user=user, field=email_field, + new_type_name='phone_number') + + model = table.get_model(attribute_names=True) + rows = model.objects.all() + + assert rows[0].name == '+45(1424) 322314 324234' + assert rows[0].phonenumber == max_length_phone_number + assert rows[0].number == '1234534532' + assert rows[0].email == '' + + assert rows[1].name == '' + assert rows[1].phonenumber == '1234567890 NnXx,+._*()#=;/ -' + assert rows[1].number == '0' + + assert rows[2].name == max_length_phone_number + assert rows[2].phonenumber == '' + assert rows[2].number == '-10230450' + + assert rows[3].name == '' + assert rows[3].phonenumber == '' + assert rows[3].number == '' + + field_handler.delete_field(user=user, field=phone_number_field) + assert len(PhoneNumberField.objects.all()) == 3 diff --git a/backend/tests/baserow/contrib/database/table/test_table_models.py b/backend/tests/baserow/contrib/database/table/test_table_models.py index a6beb3c49..818d4879e 100644 --- a/backend/tests/baserow/contrib/database/table/test_table_models.py +++ b/backend/tests/baserow/contrib/database/table/test_table_models.py @@ -161,12 +161,13 @@ def test_search_all_fields_queryset(data_fixture, user_tables_in_separate_db): date_format="US", date_include_time=True, date_time_format="24") data_fixture.create_file_field(table=table, order=6, name='File') - select = data_fixture.create_single_select_field(table=table, order=6, + select = data_fixture.create_single_select_field(table=table, order=7, name='select') option_a = data_fixture.create_select_option(field=select, value='Option A', color='blue') option_b = data_fixture.create_select_option(field=select, value='Option B', color='red') + data_fixture.create_phone_number_field(table=table, order=8, name='PhoneNumber') model = table.get_model(attribute_names=True) row_1 = model.objects.create( @@ -178,6 +179,7 @@ def test_search_all_fields_queryset(data_fixture, user_tables_in_separate_db): datetime=make_aware(datetime(4006, 7, 8, 0, 0, 0), utc), file=[{'visible_name': 'test_file.png'}], select=option_a, + phonenumber='99999' ) row_2 = model.objects.create( name='Audi', @@ -188,6 +190,7 @@ def test_search_all_fields_queryset(data_fixture, user_tables_in_separate_db): datetime=make_aware(datetime(5, 5, 5, 0, 48, 0), utc), file=[{'visible_name': 'other_file.png'}], select=option_b, + phonenumber='++--999999' ) row_3 = model.objects.create( name='Volkswagen', @@ -197,6 +200,7 @@ def test_search_all_fields_queryset(data_fixture, user_tables_in_separate_db): date='9999-05-05', datetime=make_aware(datetime(5, 5, 5, 9, 59, 0), utc), file=[], + phonenumber='' ) results = model.objects.all().search_all_fields('FASTEST') @@ -260,6 +264,15 @@ def test_search_all_fields_queryset(data_fixture, user_tables_in_separate_db): assert len(results) == 1 assert row_2 in results + results = model.objects.all().search_all_fields('999999') + assert len(results) == 1 + assert row_2 in results + + results = model.objects.all().search_all_fields('99999') + assert len(results) == 2 + assert row_1 in results + assert row_2 in results + results = model.objects.all().search_all_fields('white car') assert len(results) == 0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3381133e9..0a6d9ae09 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -18,6 +18,26 @@ def api_client(): return APIClient() +def pytest_addoption(parser): + parser.addoption( + "--runslow", action="store_true", default=False, help="run slow tests" + ) + + +def pytest_configure(config): + config.addinivalue_line("markers", "slow: mark test as slow to run") + + +def pytest_collection_modifyitems(config, items): + if config.getoption("--runslow"): + # --runslow given in cli: do not skip slow tests + return + skip_slow = pytest.mark.skip(reason="need --runslow option to run") + for item in items: + if "slow" in item.keywords: + item.add_marker(skip_slow) + + def run_non_transactional_raw_sql(sqls, dbinfo): conn = psycopg2.connect(host=dbinfo['HOST'], user=dbinfo['USER'], password=dbinfo['PASSWORD'], diff --git a/backend/tests/fixtures/field.py b/backend/tests/fixtures/field.py index ab9787411..b67ced4c4 100644 --- a/backend/tests/fixtures/field.py +++ b/backend/tests/fixtures/field.py @@ -3,7 +3,7 @@ from django.db import connections from baserow.contrib.database.fields.models import ( TextField, LongTextField, NumberField, BooleanField, DateField, LinkRowField, - FileField, SingleSelectField, SelectOption + FileField, SingleSelectField, SelectOption, URLField, EmailField, PhoneNumberField ) @@ -167,3 +167,54 @@ class FieldFixtures: self.create_model_field(kwargs['table'], field) return field + + def create_url_field(self, user=None, create_field=True, **kwargs): + if 'table' not in kwargs: + kwargs['table'] = self.create_database_table(user=user) + + if 'name' not in kwargs: + kwargs['name'] = self.fake.url() + + if 'order' not in kwargs: + kwargs['order'] = 0 + + field = URLField.objects.create(**kwargs) + + if create_field: + self.create_model_field(kwargs['table'], field) + + return field + + def create_email_field(self, user=None, create_field=True, **kwargs): + if 'table' not in kwargs: + kwargs['table'] = self.create_database_table(user=user) + + if 'name' not in kwargs: + kwargs['name'] = self.fake.email() + + if 'order' not in kwargs: + kwargs['order'] = 0 + + field = EmailField.objects.create(**kwargs) + + if create_field: + self.create_model_field(kwargs['table'], field) + + return field + + def create_phone_number_field(self, user=None, create_field=True, **kwargs): + if 'table' not in kwargs: + kwargs['table'] = self.create_database_table(user=user) + + if 'name' not in kwargs: + kwargs['name'] = self.fake.phone_number() + + if 'order' not in kwargs: + kwargs['order'] = 0 + + field = PhoneNumberField.objects.create(**kwargs) + + if create_field: + self.create_model_field(kwargs['table'], field) + + return field diff --git a/changelog.md b/changelog.md index 1cf1dc806..d37e12756 100644 --- a/changelog.md +++ b/changelog.md @@ -21,6 +21,7 @@ * Fixed SSRF bug in the file upload by URL by blocking urls to the private network. * Fixed bug where an invalid date could be converted to 0001-01-01. * The list_database_table_rows search query parameter now searches all possible field types. +* Add Phone Number field. ## Released (2021-03-01) diff --git a/docs/decisions/001-phone-number-field-validation.md b/docs/decisions/001-phone-number-field-validation.md new file mode 100644 index 000000000..fc4102ec5 --- /dev/null +++ b/docs/decisions/001-phone-number-field-validation.md @@ -0,0 +1,107 @@ +# How to Validate and Format Phone Number Fields + +See this issue for background: https://gitlab.com/bramw/baserow/-/issues/339 + +For the new number phone number field there are a number of options around how to +validate and format the field. This document captures + +# Decision + +Option 1 was chosen because: + +- It is the simplest and there are no specific user requests for any of the more complex + options below, so instead we stick to the simple option. +- We do not need to reason about the validation provided by two different open source + libraries and test that they agree which numbers are valid or not. + +## Option 1: + +Use a simple regex only allowing numbers, spaces and some punctuation to validate phone +numbers. Assume every entered phone number which passes this regex is a valid number and +display the number as a link (href="tel: {{value}}") +when appropriate so the user can call the number. + +### Pros: + +- Simplest technically +- No need to use external libraries +- Most flexible for the user as they can enter any type of telephone format +- We might be able to pass on this "validate the phone number" problem to the phone as + if it is formatted as a href="tel: xxx" link the phone might then format the opened + link + +### Cons: + +- Users can easily enter complete nonsense for phone numbers +- Its upto the user to nicely format the phone numbers every single time + +## Option 2: + +Use a [python](https://github.com/daviddrysdale/python-phonenumbers) +and [js library](https://github.com/catamphetamine/libphonenumber-js) both based +off [google's phonenumberlib](https://github.com/google/libphonenumber) and validate +that a number is "possible" which is a lower standard and less likely to change compared +to " +valid". Auto format the number based on it could be using any country code. + +### Pros + +- Dont need to store/allow configuring extra country code information +- Nice formatting for international numbers +- By using the least strict form of validation provided by the libraries we can be more + sure that the validations match between client and server. + +### Cons + +- Dont get nice country specific formatting unless the user enters a country code in the + number itself as otherwise the libraries cant detect what the country is. + +## Option 3: + +Option 2 but also allow user to specify a country code OR "international" on the whole +field, format and validate numbers entered using this country code. + +### Pros + +- Get nice formatting for local numbers if you set the column + +### Cons + +- Limits users to only having one telephone format per column, cant mix numbers without + using international +- Have to implement / design a country code select mechanism +- Have to store and sync extra country code data on the field + +## Option 4: + +Option 2, but also allow user to set a default country code on the field and then let +the user pick a country code per row which defaults to the fields default. + +### Pros + +- Users can mix every possible type of phone number in a single field and get nice + formatting and validation. +- If they dont want to mix then they can fallback to option 2 by using a default code +- If they dont want any country specific formatting they can fallback to option 1 + +### Cons + +- Have to store country data per field and row +- Have to design a row entry component which uses the default + lets users pick a + country code per field + column + +## Option 5: + +Only use a front-end library to format the entered phone numbers and check they are +posible purely in the front-end, the backend does simple or no validation. + +### Pros + +- Don't need to worry about syncing the front and back end validation +- Still get nice phone number entry and formatting + +### Cons + +- When a user converts a non-phone number field to being a phone number this happens on + the backend and hence no nice formatting will happen. Then when the user edits one + of these unformatted cells it will instantly change to be formatted. diff --git a/web-frontend/modules/core/utils/string.js b/web-frontend/modules/core/utils/string.js index d8c73a730..b400970fc 100644 --- a/web-frontend/modules/core/utils/string.js +++ b/web-frontend/modules/core/utils/string.js @@ -53,6 +53,15 @@ export const isValidEmail = (str) => { return !!pattern.test(str) } +// Regex duplicated from +// src/baserow/contrib/database/fields/field_types.py#PhoneNumberFieldType +// Docs reference what characters are valid in PhoneNumberFieldType.getDocsDescription +// Ensure they are kept in sync. +export const isSimplePhoneNumber = (str) => { + const pattern = /^[0-9NnXx,+._*()#=;/ -]{1,100}$/ + return pattern.test(str) +} + export const isSecureURL = (str) => { return str.toLowerCase().substr(0, 5) === 'https' } diff --git a/web-frontend/modules/database/components/row/RowEditFieldPhoneNumber.vue b/web-frontend/modules/database/components/row/RowEditFieldPhoneNumber.vue new file mode 100644 index 000000000..7b736c15c --- /dev/null +++ b/web-frontend/modules/database/components/row/RowEditFieldPhoneNumber.vue @@ -0,0 +1,27 @@ +<template> + <div class="control__elements"> + <input + ref="input" + v-model="copy" + type="tel" + class="input input--large" + :class="{ 'input--error': !isValid() }" + @keyup.enter="$refs.input.blur()" + @focus="select()" + @blur="unselect()" + /> + <div v-show="!isValid()" class="error"> + {{ getError() }} + </div> + </div> +</template> + +<script> +import rowEditField from '@baserow/modules/database/mixins/rowEditField' +import rowEditFieldInput from '@baserow/modules/database/mixins/rowEditFieldInput' +import phoneNumberField from '@baserow/modules/database/mixins/phoneNumberField' + +export default { + mixins: [rowEditField, rowEditFieldInput, phoneNumberField], +} +</script> diff --git a/web-frontend/modules/database/components/view/grid/fields/FunctionalGridViewFieldPhoneNumber.vue b/web-frontend/modules/database/components/view/grid/fields/FunctionalGridViewFieldPhoneNumber.vue new file mode 100644 index 000000000..57096dbce --- /dev/null +++ b/web-frontend/modules/database/components/view/grid/fields/FunctionalGridViewFieldPhoneNumber.vue @@ -0,0 +1,7 @@ +<template functional> + <div ref="cell" class="grid-view__cell"> + <div class="grid-field-text"> + {{ props.value }} + </div> + </div> +</template> diff --git a/web-frontend/modules/database/components/view/grid/fields/GridViewFieldPhoneNumber.vue b/web-frontend/modules/database/components/view/grid/fields/GridViewFieldPhoneNumber.vue new file mode 100644 index 000000000..7a47ba782 --- /dev/null +++ b/web-frontend/modules/database/components/view/grid/fields/GridViewFieldPhoneNumber.vue @@ -0,0 +1,43 @@ +<template> + <div + class="grid-view__cell active" + :class="{ + editing: editing, + invalid: editing && !isValid(), + }" + @contextmenu="stopContextIfEditing($event)" + > + <div v-show="!editing" class="grid-field-text"> + <a :href="'tel:' + value" target="_blank">{{ value }}</a> + </div> + <template v-if="editing"> + <input + ref="input" + v-model="copy" + type="tel" + class="grid-field-text__input" + /> + <div v-show="!isValid()" class="grid-view__cell--error align-right"> + {{ getError() }} + </div> + </template> + </div> +</template> + +<script> +import gridField from '@baserow/modules/database/mixins/gridField' +import gridFieldInput from '@baserow/modules/database/mixins/gridFieldInput' +import phoneNumberField from '@baserow/modules/database/mixins/phoneNumberField' + +export default { + mixins: [gridField, gridFieldInput, phoneNumberField], + methods: { + afterEdit() { + this.$nextTick(() => { + this.$refs.input.focus() + this.$refs.input.selectionStart = this.$refs.input.selectionEnd = 100000 + }) + }, + }, +} +</script> diff --git a/web-frontend/modules/database/fieldTypes.js b/web-frontend/modules/database/fieldTypes.js index 2d620d2af..9932f5fe4 100644 --- a/web-frontend/modules/database/fieldTypes.js +++ b/web-frontend/modules/database/fieldTypes.js @@ -1,7 +1,11 @@ import moment from 'moment' import BigNumber from 'bignumber.js' -import { isValidURL, isValidEmail } from '@baserow/modules/core/utils/string' +import { + isValidURL, + isValidEmail, + isSimplePhoneNumber, +} from '@baserow/modules/core/utils/string' import { Registerable } from '@baserow/modules/core/registry' import FieldNumberSubForm from '@baserow/modules/database/components/field/FieldNumberSubForm' @@ -20,6 +24,7 @@ import GridViewFieldBoolean from '@baserow/modules/database/components/view/grid import GridViewFieldDate from '@baserow/modules/database/components/view/grid/fields/GridViewFieldDate' import GridViewFieldFile from '@baserow/modules/database/components/view/grid/fields/GridViewFieldFile' import GridViewFieldSingleSelect from '@baserow/modules/database/components/view/grid/fields/GridViewFieldSingleSelect' +import GridViewFieldPhoneNumber from '@baserow/modules/database/components/view/grid/fields/GridViewFieldPhoneNumber' import FunctionalGridViewFieldText from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldText' import FunctionalGridViewFieldLongText from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldLongText' @@ -29,6 +34,7 @@ import FunctionalGridViewFieldBoolean from '@baserow/modules/database/components import FunctionalGridViewFieldDate from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldDate' import FunctionalGridViewFieldFile from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldFile' import FunctionalGridViewFieldSingleSelect from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldSingleSelect' +import FunctionalGridViewFieldPhoneNumber from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldPhoneNumber' import RowEditFieldText from '@baserow/modules/database/components/row/RowEditFieldText' import RowEditFieldLongText from '@baserow/modules/database/components/row/RowEditFieldLongText' @@ -40,6 +46,7 @@ import RowEditFieldBoolean from '@baserow/modules/database/components/row/RowEdi import RowEditFieldDate from '@baserow/modules/database/components/row/RowEditFieldDate' import RowEditFieldFile from '@baserow/modules/database/components/row/RowEditFieldFile' import RowEditFieldSingleSelect from '@baserow/modules/database/components/row/RowEditFieldSingleSelect' +import RowEditFieldPhoneNumber from '@baserow/modules/database/components/row/RowEditFieldPhoneNumber' import { trueString } from '@baserow/modules/database/utils/constants' import { @@ -1129,3 +1136,65 @@ export class SingleSelectFieldType extends FieldType { } } } + +export class PhoneNumberFieldType extends FieldType { + static getType() { + return 'phone_number' + } + + getIconClass() { + return 'phone' + } + + getName() { + return 'Phone Number' + } + + getGridViewFieldComponent() { + return GridViewFieldPhoneNumber + } + + getFunctionalGridViewFieldComponent() { + return FunctionalGridViewFieldPhoneNumber + } + + getRowEditFieldComponent() { + return RowEditFieldPhoneNumber + } + + prepareValueForPaste(field, clipboardData) { + const value = clipboardData.getData('text') + return isSimplePhoneNumber(value) ? value : '' + } + + getSort(name, order) { + return (a, b) => { + const stringA = a[name] === null ? '' : '' + a[name] + const stringB = b[name] === null ? '' : '' + b[name] + + return order === 'ASC' + ? stringA.localeCompare(stringB) + : stringB.localeCompare(stringA) + } + } + + getSortIndicator() { + return ['text', '0', '9'] + } + + getDocsDataType(field) { + return 'string' + } + + getDocsDescription(field) { + return ( + 'Accepts a phone number which has a maximum length of 100 characters' + + ' consisting solely of digits, spaces and the following characters: ' + + 'Nx,._+*()#=;/- .' + ) + } + + getDocsRequestExample(field) { + return '+1-541-754-3010' + } +} diff --git a/web-frontend/modules/database/mixins/phoneNumberField.js b/web-frontend/modules/database/mixins/phoneNumberField.js new file mode 100644 index 000000000..bee7f6cac --- /dev/null +++ b/web-frontend/modules/database/mixins/phoneNumberField.js @@ -0,0 +1,26 @@ +/** + * This mixin contains some method overrides for validating and formatting the + * phone number field. This mixin is used in both the GridViewFieldPhoneNumber and + * RowEditFieldPhoneNumber components. + */ +import { isSimplePhoneNumber } from '@baserow/modules/core/utils/string' + +export default { + methods: { + /** + * Generates a human readable error for the user if something is wrong. + */ + getError() { + if (this.copy === null || this.copy === '') { + return null + } + if (!isSimplePhoneNumber(this.copy)) { + return 'Invalid Phone Number' + } + return null + }, + isValid() { + return this.getError() === null + }, + }, +} diff --git a/web-frontend/modules/database/plugin.js b/web-frontend/modules/database/plugin.js index f6153ec08..3f01af705 100644 --- a/web-frontend/modules/database/plugin.js +++ b/web-frontend/modules/database/plugin.js @@ -11,6 +11,7 @@ import { DateFieldType, FileFieldType, SingleSelectFieldType, + PhoneNumberFieldType, } from '@baserow/modules/database/fieldTypes' import { EqualViewFilterType, @@ -73,6 +74,7 @@ export default ({ store, app }) => { app.$registry.register('field', new EmailFieldType()) app.$registry.register('field', new FileFieldType()) app.$registry.register('field', new SingleSelectFieldType()) + app.$registry.register('field', new PhoneNumberFieldType()) app.$registry.register('importer', new CSVImporterType()) app.$registry.register('importer', new PasteImporterType()) app.$registry.register('settings', new APITokenSettingsType()) diff --git a/web-frontend/modules/database/viewFilters.js b/web-frontend/modules/database/viewFilters.js index 8eaf7dc11..90310a150 100644 --- a/web-frontend/modules/database/viewFilters.js +++ b/web-frontend/modules/database/viewFilters.js @@ -98,7 +98,7 @@ export class EqualViewFilterType extends ViewFilterType { } getCompatibleFieldTypes() { - return ['text', 'long_text', 'url', 'email', 'number'] + return ['text', 'long_text', 'url', 'email', 'number', 'phone_number'] } matches(rowValue, filterValue) { @@ -126,7 +126,7 @@ export class NotEqualViewFilterType extends ViewFilterType { } getCompatibleFieldTypes() { - return ['text', 'long_text', 'url', 'email', 'number'] + return ['text', 'long_text', 'url', 'email', 'number', 'phone_number'] } matches(rowValue, filterValue) { @@ -193,7 +193,7 @@ export class ContainsViewFilterType extends ViewFilterType { } getCompatibleFieldTypes() { - return ['text', 'long_text', 'url', 'email'] + return ['text', 'long_text', 'url', 'email', 'phone_number'] } matches(rowValue, filterValue) { @@ -217,7 +217,7 @@ export class ContainsNotViewFilterType extends ViewFilterType { } getCompatibleFieldTypes() { - return ['text', 'long_text', 'url', 'email'] + return ['text', 'long_text', 'url', 'email', 'phone_number'] } matches(rowValue, filterValue) { @@ -475,6 +475,7 @@ export class EmptyViewFilterType extends ViewFilterType { 'link_row', 'file', 'single_select', + 'phone_number', ] } @@ -517,6 +518,7 @@ export class NotEmptyViewFilterType extends ViewFilterType { 'link_row', 'file', 'single_select', + 'phone_number', ] }