Repository: flask-admin/flask-admin

Issue:
cannot search on hybrid property
When adding a hybrid property to `column_searchable_list`, e.g. the `phone_number` field in the *sqla* example app, I get the following error:

```
Exception: Invalid field User.phone_number: does not contains any columns.
```


Discussion:

Resolving patch:
diff --git a/examples/sqla/app.py b/examples/sqla/app.py
--- a/examples/sqla/app.py
+++ b/examples/sqla/app.py
@@ -4,6 +4,7 @@
 from flask_sqlalchemy import SQLAlchemy
 from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.orm import composite
+from sqlalchemy import sql, cast
 import uuid
 import random
 import string
@@ -85,9 +86,13 @@ class User(db.Model):
     def phone_number(self):
         if self.dialling_code and self.local_phone_number:
             number = str(self.local_phone_number)
-            return "+{} ({}){} {} {}".format(self.dialling_code, number[0], number[1:3], number[3:6], number[6::])
+            return "+{} ({}) {} {} {}".format(self.dialling_code, number[0], number[1:3], number[3:6], number[6::])
         return
 
+    @phone_number.expression
+    def phone_number(cls):
+        return sql.operators.ColumnOperators.concat(cast(cls.dialling_code, db.String), cls.local_phone_number)
+
     def __str__(self):
         return "{}, {}".format(self.last_name, self.first_name)
 
@@ -197,6 +202,7 @@ class UserAdmin(sqla.ModelView):
     column_searchable_list = [
         'first_name',
         'last_name',
+        'phone_number',
         'email',
     ]
     column_editable_list = ['type', 'currency', 'timezone']
@@ -233,6 +239,7 @@ class UserAdmin(sqla.ModelView):
         FilterEqual(column=User.last_name, name='Last Name'),
         FilterLastNameBrown(column=User.last_name, name='Last Name',
                             options=(('1', 'Yes'), ('0', 'No'))),
+        'phone_number',
         'email',
         'ip_address',
         'currency',
diff --git a/flask_admin/contrib/sqla/view.py b/flask_admin/contrib/sqla/view.py
--- a/flask_admin/contrib/sqla/view.py
+++ b/flask_admin/contrib/sqla/view.py
@@ -572,14 +572,20 @@ def init_search(self):
         if self.column_searchable_list:
             self._search_fields = []
 
-            for p in self.column_searchable_list:
-                attr, joins = tools.get_field_with_path(self.model, p)
+            for name in self.column_searchable_list:
+                attr, joins = tools.get_field_with_path(self.model, name)
 
                 if not attr:
-                    raise Exception('Failed to find field for search field: %s' % p)
+                    raise Exception('Failed to find field for search field: %s' % name)
 
-                for column in tools.get_columns_for_field(attr):
+                if tools.is_hybrid_property(self.model, name):
+                    column = attr
+                    if isinstance(name, string_types):
+                        column.key = name.split('.')[-1]
                     self._search_fields.append((column, joins))
+                else:
+                    for column in tools.get_columns_for_field(attr):
+                        self._search_fields.append((column, joins))
 
         return bool(self.column_searchable_list)
 


Test patch:
diff --git a/flask_admin/tests/sqla/test_basic.py b/flask_admin/tests/sqla/test_basic.py
--- a/flask_admin/tests/sqla/test_basic.py
+++ b/flask_admin/tests/sqla/test_basic.py
@@ -10,6 +10,7 @@
 from flask_babelex import Babel
 
 from sqlalchemy.ext.hybrid import hybrid_property
+from sqlalchemy import cast
 from sqlalchemy_utils import EmailType, ChoiceType, UUIDType, URLType, CurrencyType, ColorType, ArrowType, IPAddressType
 from . import setup
 
@@ -1559,6 +1560,14 @@ class Model1(db.Model):
         def number_of_pixels(self):
             return self.width * self.height
 
+        @hybrid_property
+        def number_of_pixels_str(self):
+            return str(self.number_of_pixels())
+
+        @number_of_pixels_str.expression
+        def number_of_pixels_str(cls):
+            return cast(cls.width * cls.height, db.String)
+
     db.create_all()
 
     db.session.add(Model1(id=1, name="test_row_1", width=25, height=25))
@@ -1571,7 +1580,8 @@ def number_of_pixels(self):
         Model1, db.session,
         column_default_sort='number_of_pixels',
         column_filters=[filters.IntGreaterFilter(Model1.number_of_pixels,
-                                                 'Number of Pixels')]
+                                                 'Number of Pixels')],
+        column_searchable_list=['number_of_pixels_str', ]
     )
     admin.add_view(view)
 
@@ -1592,6 +1602,13 @@ def number_of_pixels(self):
     eq_(data[0].name, 'test_row_2')
     eq_(data[1].name, 'test_row_1')
 
+    # searching
+    rv = client.get('/admin/model1/?search=100')
+    eq_(rv.status_code, 200)
+    data = rv.data.decode('utf-8')
+    ok_('test_row_2' in data)
+    ok_('test_row_1' not in data)
+
 
 def test_url_args():
     app, db, admin = setup()