From 1a714fae5082a62471d9fbfeefc5e74bd2276dfc Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 7 Feb 2019 18:50:30 +0000 Subject: [PATCH 001/109] New, CRUD REST Api using marshmallow --- examples/quickhowto/app/models.py | 14 +- examples/quickhowto/app/views.py | 22 +- examples/quickhowto/config.py | 1 + flask_appbuilder/api.py | 720 ++++++++++++++++++++++++ flask_appbuilder/babel/manager.py | 6 +- flask_appbuilder/base.py | 4 + flask_appbuilder/baseapp.py | 6 - flask_appbuilder/models/filters.py | 35 +- flask_appbuilder/models/sqla/filters.py | 19 +- flask_appbuilder/security/decorators.py | 2 +- flask_appbuilder/version.py | 6 +- rtd_requirements.txt | 3 + setup.py | 3 + 13 files changed, 820 insertions(+), 21 deletions(-) create mode 100644 flask_appbuilder/api.py delete mode 100644 flask_appbuilder/baseapp.py diff --git a/examples/quickhowto/app/models.py b/examples/quickhowto/app/models.py index 73d931704a..a162dd811d 100644 --- a/examples/quickhowto/app/models.py +++ b/examples/quickhowto/app/models.py @@ -2,6 +2,7 @@ from sqlalchemy import Column, Integer, String, ForeignKey, Date from sqlalchemy.orm import relationship from flask_appbuilder import Model +from marshmallow import Schema, fields mindate = datetime.date(datetime.MINYEAR, 1, 1) @@ -13,6 +14,9 @@ class ContactGroup(Model): def __repr__(self): return self.name +class ContactGroupSchema(Schema): + name = fields.Str(required=True) + class Gender(Model): id = Column(Integer, primary_key=True) @@ -44,4 +48,12 @@ def month_year(self): def year(self): date = self.birthday or mindate return datetime.datetime(date.year, 1, 1) - + +class ContactSchema(Schema): + name = fields.Str(required=True) + address = fields.Str() + birthday = fields.Date() + personal_phone = fields.Str() + personal_celphone = fields.Str() + contact_group = fields.Nested("ContactGroupSchema", required=True) + gender = relationship("Gender") diff --git a/examples/quickhowto/app/views.py b/examples/quickhowto/app/views.py index 2a44df1cdd..abca7785cf 100644 --- a/examples/quickhowto/app/views.py +++ b/examples/quickhowto/app/views.py @@ -3,12 +3,12 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_appbuilder.charts.views import GroupByChartView from flask_appbuilder.models.group import aggregate_count -from flask_appbuilder.widgets import FormHorizontalWidget, FormInlineWidget, FormVerticalWidget from flask_babel import lazy_gettext as _ - +from flask_appbuilder.api import ModelApi +from flask_appbuilder.security.sqla.models import User from app import db, appbuilder -from .models import ContactGroup, Gender, Contact +from .models import ContactGroup, Gender, Contact, ContactGroupSchema, ContactSchema def fill_gender(): @@ -20,6 +20,7 @@ def fill_gender(): db.session.rollback() + class ContactModelView(ModelView): datamodel = SQLAInterface(Contact) @@ -52,6 +53,17 @@ class GroupModelView(ModelView): datamodel = SQLAInterface(ContactGroup) related_views = [ContactModelView] +class GroupModelApi(ModelApi): + datamodel = SQLAInterface(ContactGroup) + +class ContactModelApi(ModelApi): + datamodel = SQLAInterface(Contact) + #show_columns = ['name'] + #list_model_schema = ContactSchema() + +class UserModelApi(ModelApi): + datamodel = SQLAInterface(User) + class ContactChartView(GroupByChartView): datamodel = SQLAInterface(Contact) @@ -100,6 +112,10 @@ class ContactTimeChartView(GroupByChartView): db.create_all() fill_gender() +appbuilder.add_view_no_menu(GroupModelApi) +appbuilder.add_view_no_menu(UserModelApi) +appbuilder.add_view_no_menu(ContactModelApi) + appbuilder.add_view(GroupModelView, "List Groups", icon="fa-folder-open-o", category="Contacts", category_icon='fa-envelope') appbuilder.add_view(ContactModelView, "List Contacts", icon="fa-envelope", category="Contacts") appbuilder.add_separator("Contacts") diff --git a/examples/quickhowto/config.py b/examples/quickhowto/config.py index bba522f789..d7fce5cd30 100644 --- a/examples/quickhowto/config.py +++ b/examples/quickhowto/config.py @@ -4,6 +4,7 @@ CSRF_ENABLED = True SECRET_KEY = '\2\1thisismyscretkey\1\2\e\y\y\h' +JSON_AS_ASCII = False OPENID_PROVIDERS = [ {'name': 'Google', 'url': 'https://www.google.com/accounts/o8/id'}, diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py new file mode 100644 index 0000000000..2283f43eaa --- /dev/null +++ b/flask_appbuilder/api.py @@ -0,0 +1,720 @@ +import re +import json +import logging +import functools +from flask import Blueprint, session, flash, \ + render_template, url_for, abort, make_response, jsonify, request +from flask_babel import lazy_gettext as _ +from datetime import datetime, date +from .security.decorators import has_access, permission_name, has_access_api +from marshmallow import ValidationError +from ._compat import as_unicode, string_types + +log = logging.getLogger(__name__) + +URI_ORDER_BY_PREFIX="_o_" +URI_PAGE_PREFIX="_p_" +URI_FILTER_PREFIX="_f_" + +def order_args(f): + """ + Get order arguments decorator + { : (ORDER_COL, ORDER_DIRECTION) } + + Arguments are passed like: _o_=:'' + + function is called with named args: order_column, order_direction + """ + def wraps(self, *args, **kwargs): + orders = {} + for arg, value in request.args.items(): + if arg==URI_ORDER_BY_PREFIX: + re_match = re.findall('(.*):(.*)', value) + for _item_re_match in re_match: + if _item_re_match and _item_re_match[1] in ('asc', 'desc'): + orders[_item_re_match[0]] = _item_re_match[1] + if orders: + for order_col, order_dir in orders.items(): + order_column, order_direction = order_col, order_dir + else: + order_column, order_direction = '', '' + kwargs['order_column'] = order_column + kwargs['order_direction'] = order_direction + return f(self, *args, order_column=order_column, order_direction=order_direction) + return functools.update_wrapper(wraps, f) + + +def page_args(f): + """ + Get page arguments decorator + { : (ORDER_COL, ORDER_DIRECTION) } + + Arguments are passed like: _p_=:'' + + function is called with named args: page_size, page_index + """ + def wraps(self, *args, **kwargs): + for arg, value in request.args.items(): + if arg==URI_PAGE_PREFIX: + re_match = re.findall('(.*):(.*)', value) + for _item_re_match in re_match: + if _item_re_match and len(_item_re_match) == 2: + try: + kwargs['page_size'] = int(_item_re_match[0]) + kwargs['page_index'] = int(_item_re_match[1]) + return f(self, *args, **kwargs) + except ValueError as e: + log.warn("Bad page args {}, {}".format(_item_re_match[0], _item_re_match[1])) + kwargs['page_size'] = self.page_size + kwargs['page_index'] = 0 + return f(self, *args, **kwargs) + return functools.update_wrapper(wraps, f) + + +def filter_args(f): + """ + Get filter arguments, return a list of dicts + { : (ORDER_COL, ORDER_DIRECTION) } + + Arguments are passed like: _f_=:: + + :return: list [ + { + "col_name":"", + "operator":"", + "value":"", + } + ] + """ + def wraps(self, *args, **kwargs): + filters = list() + for arg, value in request.args.items(): + key_match = re.match("{}(\d)".format(URI_FILTER_PREFIX), arg) + if key_match: + key_index = key_match[0] + re_match = re.findall('(.*):(.*):(.*)', value) + for _item_re_match in re_match: + if _item_re_match and len(_item_re_match) == 3: + filters.append({"col_name": _item_re_match[0], + "operator": _item_re_match[1], + "value": _item_re_match[2], + }) + else: + log.warn("Bar filter args {} ".format(_item_re_match)) + kwargs['filters'] = filters + return f(self, *args, **kwargs) + return functools.update_wrapper(wraps, f) + + + +def expose(url='/', methods=('GET',)): + """ + Use this decorator to expose views on your view classes. + + :param url: + Relative URL for the view + :param methods: + Allowed HTTP methods. By default only GET is allowed. + """ + + def wrap(f): + if not hasattr(f, '_urls'): + f._urls = [] + f._urls.append((url, methods)) + return f + return wrap + + +class BaseApi: + """ + All apis inherit from this class. + it's constructor will register your exposed urls on flask as a Blueprint. + + This class does not expose any urls, but provides a common base for all apis. + """ + + appbuilder = None + blueprint = None + endpoint = None + + version = 'v1' + route_base = None + + base_permissions = None + extra_args = None + + def __init__(self): + """ + Initialization of base permissions + based on exposed methods and actions + + Initialization of extra args + """ + if self.base_permissions is None: + self.base_permissions = set() + for attr_name in dir(self): + if hasattr(getattr(self, attr_name), '_permission_name'): + permission_name = getattr(getattr(self, attr_name), '_permission_name') + self.base_permissions.add('can_' + permission_name) + self.base_permissions = list(self.base_permissions) + if not self.extra_args: + self.extra_args = dict() + self._apis = dict() + for attr_name in dir(self): + if hasattr(getattr(self, attr_name), '_extra'): + _extra = getattr(getattr(self, attr_name), '_extra') + for key in _extra: self._apis[key] = _extra[key] + + def create_blueprint(self, appbuilder, + endpoint=None, + static_folder=None): + # Store appbuilder instance + self.appbuilder = appbuilder + # If endpoint name is not provided, get it from the class name + self.endpoint = endpoint or self.__class__.__name__ + + if self.route_base is None: + self.route_base = \ + "/api/{}/{}".format(self.version, self.__class__.__name__.lower()) + self.blueprint = Blueprint(self.endpoint, __name__, + url_prefix=self.route_base) + + self._register_urls() + return self.blueprint + + + def _register_urls(self): + for attr_name in dir(self): + attr = getattr(self, attr_name) + if hasattr(attr, '_urls'): + for url, methods in attr._urls: + self.blueprint.add_url_rule(url, + attr_name, + attr, + methods=methods) + + def _prettify_name(self, name): + """ + Prettify pythonic variable name. + + For example, 'HelloWorld' will be converted to 'Hello World' + + :param name: + Name to prettify. + """ + return re.sub(r'(?<=.)([A-Z])', r' \1', name) + + def _prettify_column(self, name): + """ + Prettify pythonic variable name. + + For example, 'hello_world' will be converted to 'Hello World' + + :param name: + Name to prettify. + """ + return re.sub('[._]', ' ', name).title() + + def get_uninit_inner_views(self): + """ + Will return a list with views that need to be initialized. + Normally related_views from ModelView + """ + return [] + + def get_init_inner_views(self, views): + """ + Sets initialized inner views + """ + pass + + +class BaseModelApi(BaseApi): + datamodel = None + """ + Your sqla model you must initialize it like:: + + class MyModelApi(BaseModelApi): + datamodel = SQLAInterface(MyTable) + """ + search_columns = None + """ + List with allowed search columns, if not provided all possible search columns will be used + If you want to limit the search (*filter*) columns possibilities, define it with a list of column names from your model:: + + class MyView(ModelView): + datamodel = SQLAInterface(MyTable) + search_columns = ['name','address'] + + """ + search_exclude_columns = None + """ + List with columns to exclude from search. Search includes all possible columns by default + """ + label_columns = None + """ + Dictionary of labels for your columns, override this if you want different pretify labels + + example (will just override the label for name column):: + + class MyView(ModelApi): + datamodel = SQLAInterface(MyTable) + label_columns = {'name':'My Name Label Override'} + + """ + base_filters = None + """ + Filter the view use: [['column_name',BaseFilter,'value'],] + + example:: + + def get_user(): + return g.user + + class MyView(ModelApi): + datamodel = SQLAInterface(MyTable) + base_filters = [['created_by', FilterEqualFunction, get_user], + ['name', FilterStartsWith, 'a']] + + """ + + base_order = None + """ + Use this property to set default ordering for lists ('col_name','asc|desc'):: + + class MyView(ModelApi): + datamodel = SQLAInterface(MyTable) + base_order = ('my_column_name','asc') + + """ + _base_filters = None + """ Internal base Filter from class Filters will always filter view """ + _filters = None + """ Filters object will calculate all possible filter types based on search_columns """ + list_model_schema = None + add_model_schema = None + edit_model_schema = None + show_model_schema = None + + def __init__(self, **kwargs): + """ + Constructor + """ + datamodel = kwargs.get('datamodel', None) + if datamodel: + self.datamodel = datamodel + self._init_properties() + self._init_titles() + super(BaseModelApi, self).__init__() + + def _gen_labels_columns(self, list_columns): + """ + Auto generates pretty label_columns from list of columns + """ + for col in list_columns: + if not self.label_columns.get(col): + self.label_columns[col] = self._prettify_column(col) + + def _label_columns_json(self): + """ + Prepares dict with labels to be JSON serializable + """ + ret = {} + for key, value in list(self.label_columns.items()): + ret[key] = as_unicode(_(value).encode('UTF-8')) + return ret + + def _description_columns_json(self): + """ + Prepares dict with col descriptions to be JSON serializable + """ + ret = {} + for key, value in list(self.description_columns.items()): + ret[key] = as_unicode(_(value).encode('UTF-8')) + return ret + + def _init_properties(self): + self.label_columns = self.label_columns or {} + self.base_filters = self.base_filters or [] + self.search_exclude_columns = self.search_exclude_columns or [] + self.search_columns = self.search_columns or [] + + self._base_filters = self.datamodel.get_filters().add_filter_list(self.base_filters) + list_cols = self.datamodel.get_columns_list() + search_columns = self.datamodel.get_search_columns_list() + if not self.search_columns: + self.search_columns = [x for x in search_columns if x not in self.search_exclude_columns] + + self._gen_labels_columns(list_cols) + self._filters = self.datamodel.get_filters(self.search_columns) + + def _init_titles(self): + pass + +class ModelApi(BaseModelApi): + list_title = "" + """ List Title, if not configured the default is 'List ' with pretty model name """ + show_title = "" + """ Show Title , if not configured the default is 'Show ' with pretty model name """ + add_title = "" + """ Add Title , if not configured the default is 'Add ' with pretty model name """ + edit_title = "" + """ Edit Title , if not configured the default is 'Edit ' with pretty model name """ + + list_columns = None + """ + A list of columns (or model's methods) to be displayed on the list view. + Use it to control the order of the display + """ + show_columns = None + """ + A list of columns (or model's methods) to be displayed on the show view. + Use it to control the order of the display + """ + add_columns = None + """ + A list of columns (or model's methods) to be displayed on the add form view. + Use it to control the order of the display + """ + edit_columns = None + """ + A list of columns (or model's methods) to be displayed on the edit form view. + Use it to control the order of the display + """ + show_exclude_columns = None + """ + A list of columns to exclude from the show view. By default all columns are included. + """ + add_exclude_columns = None + """ + A list of columns to exclude from the add form. By default all columns are included. + """ + edit_exclude_columns = None + """ + A list of columns to exclude from the edit form. By default all columns are included. + """ + order_columns = None + """ Allowed order columns """ + page_size = 10 + """ + Use this property to change default page size + """ + description_columns = None + """ + Dictionary with column descriptions that will be shown on the forms:: + + class MyView(ModelView): + datamodel = SQLAModel(MyTable, db.session) + + description_columns = {'name':'your models name column','address':'the address column'} + """ + formatters_columns = None + """ Dictionary of formatter used to format the display of columns + + formatters_columns = {'some_date_col': lambda x: x.isoformat() } + """ + def create_blueprint(self, appbuilder, *args, **kwargs): + self._init_model_schemas() + return super(ModelApi, self).create_blueprint(appbuilder, *args, **kwargs) + + def _init_model_schemas(self): + class ListMetaSchema(self.appbuilder.marshmallow.ModelSchema): + class Meta: + model = self.datamodel.obj + fields = self.list_columns + strict = True + class AddMetaSchema(self.appbuilder.marshmallow.ModelSchema): + class Meta: + model = self.datamodel.obj + fields = self.add_columns + strict = True + class EditMetaSchema(self.appbuilder.marshmallow.ModelSchema): + class Meta: + model = self.datamodel.obj + fields = self.edit_columns + strict = True + class ShowMetaSchema(self.appbuilder.marshmallow.ModelSchema): + class Meta: + model = self.datamodel.obj + fields = self.show_columns + strict = True + + # Create Marshmalow schemas if one is not specified + if self.list_model_schema is None: + self.list_model_schema = ListMetaSchema() + if self.add_model_schema is None: + self.add_model_schema = AddMetaSchema() + if self.edit_model_schema is None: + self.edit_model_schema = EditMetaSchema() + if self.show_model_schema is None: + self.show_model_schema = ShowMetaSchema() + + def _init_titles(self): + """ + Init Titles if not defined + """ + super(ModelApi, self)._init_titles() + class_name = self.datamodel.model_name + if not self.list_title: + self.list_title = 'List ' + self._prettify_name(class_name) + if not self.add_title: + self.add_title = 'Add ' + self._prettify_name(class_name) + if not self.edit_title: + self.edit_title = 'Edit ' + self._prettify_name(class_name) + if not self.show_title: + self.show_title = 'Show ' + self._prettify_name(class_name) + self.title = self.list_title + + def _init_properties(self): + """ + Init Properties + """ + super(ModelApi, self)._init_properties() + # Reset init props + self.description_columns = self.description_columns or {} + self.formatters_columns = self.formatters_columns or {} + self.show_exclude_columns = self.show_exclude_columns or [] + self.add_exclude_columns = self.add_exclude_columns or [] + self.edit_exclude_columns = self.edit_exclude_columns or [] + # Generate base props + list_cols = self.datamodel.get_user_columns_list() + if not self.list_columns and self.list_model_schema: + self.list_columns = list(self.list_model_schema._declared_fields.keys()) + else: + self.list_columns = self.list_columns or self.datamodel.get_columns_list() + self._gen_labels_columns(self.list_columns) + self.order_columns = self.order_columns or self.datamodel.get_order_columns_list(list_columns=self.list_columns) + # Process excluded columns + if not self.show_columns: + self.show_columns = [x for x in list_cols if x not in self.show_exclude_columns] + if not self.add_columns: + self.add_columns = [x for x in list_cols if x not in self.add_exclude_columns] + if not self.edit_columns: + self.edit_columns = [x for x in list_cols if x not in self.edit_exclude_columns] + self._filters = self.datamodel.get_filters(self.search_columns) + + @expose('/info', methods=['GET']) + @permission_name('get') + def info(self): + search_filters = dict() + dict_filters = self._filters.get_search_filters() + for col in self.search_columns: + search_filters[col] = [ + {'name': as_unicode(flt.name), + 'operator' : flt.arg_name} for flt in dict_filters[col] + ] + return self._api_json_response(200, filters=search_filters) + + @expose('/', methods=['GET']) + @expose('//', methods=['GET']) + @permission_name('get') + def get(self, pk=None): + if not pk: + return self._get_list() + return self._get_item(pk) + + @expose('/', methods=['POST']) + @permission_name('post') + def post(self): + try: + item = self.add_model_schema.load(request.json) + except ValidationError as err: + ret_code = 400 + message = err.messages + else: + self.pre_add(item.data) + if self.datamodel.add(item.data): + self.post_add(item.data) + ret_code = 200 + message = 'OK' + else: + ret_code = 500 + message = 'NOTOK' + return self._api_json_response(ret_code, message=message) + + @expose('/', methods=['PUT']) + @permission_name('put') + def put(self, pk): + item = self.datamodel.get(pk) + if not item: + ret_code = 404 + message = 'Not found' + else: + try: + item = self.edit_model_schema.load(request.json, instance=item) + except ValidationError as err: + ret_code = 400 + message = err.messages + else: + self.pre_update(item.data) + if self.datamodel.edit(item.data): + self.post_add(item) + ret_code = 200 + message = 'OK' + self.post_update(item) + else: + ret_code = 500 + message = 'NOTOK' + return self._api_json_response(ret_code, message=message) + + @expose('/', methods=['DELETE']) + @permission_name('delete') + def delete(self, pk): + item = self.datamodel.get(pk, self._base_filters) + if not item: + ret_code = 404 + message = "Not found" + else: + self.pre_delete(item) + if self.datamodel.delete(item): + self.post_delete(item) + ret_code = 200 + message = 'OK' + else: + ret_code = 500 + message = 'NOTOK' + return self._api_json_response(ret_code, message=message) + + def _get_item(self, pk): + item = self.datamodel.get(pk) + if not item: + abort(404) + return self._api_json_response(200, pk=pk, + label_columns=self._label_columns_json(), + include_columns=self.show_columns, + description_columns=self._description_columns_json(), + modelview_name=self.__class__.__name__, + result=self.show_model_schema.dump(item, many=False).data) + + @order_args + @page_args + @filter_args + def _get_list(self, filters=None, order_column=None, order_direction=None, + page_size=0, page_index=0): + + self._filters.clear_filters() + self._filters.rest_add_filters(filters) + # Make query + count, lst = self.datamodel.query(self._filters, order_column, order_direction, + page=page_index, page_size=page_size) + pks = self.datamodel.get_keys(lst) + print("DESC {}".format(self._description_columns_json())) + return self._api_json_response(200, + label_columns=self._label_columns_json(), + list_columns=self.list_columns, + description_columns=self._description_columns_json(), + modelview_name=self.__class__.__name__, + count=count, + ids=pks, + result=self.list_model_schema.dump(lst, many=True).data + ) + + @staticmethod + def _api_json_response(code, **kwargs): + _ret_json = jsonify(kwargs) + response = make_response(_ret_json, code) + response.headers['Content-Type'] = "application/json; charset=utf-8" + return response + + """ + ------------------------------------------------ + HELPER FUNCTIONS + ------------------------------------------------ + """ + def show_item_dict(self, item): + """Returns a json-able dict for show""" + d = {} + for col in self.show_columns: + v = getattr(item, col) + if not isinstance(v, (int, float, string_types)): + v = str(v) + d[col] = v + return d + + + def _serialize_pk_if_composite(self, pk): + def date_serializer(obj): + if isinstance(obj, datetime): + return { + "_type": "datetime", + "value": obj.isoformat() + } + elif isinstance(obj, date): + return { + "_type": "date", + "value": obj.isoformat() + } + + if self.datamodel.is_pk_composite(): + try: + pk = json.dumps(pk, default=date_serializer) + except: + pass + return pk + + def _deserialize_pk_if_composite(self, pk): + def date_deserializer(obj): + if '_type' not in obj: + return obj + + from dateutil import parser + if obj['_type'] == 'datetime': + return parser.parse(obj['value']) + elif obj['_type'] == 'date': + return parser.parse(obj['value']).date() + return obj + + if self.datamodel.is_pk_composite(): + try: + pk = json.loads(pk, object_hook=date_deserializer) + except: + pass + return pk + + def pre_update(self, item): + """ + Override this, this method is called before the update takes place. + If an exception is raised by this method, + the message is shown to the user and the update operation is + aborted. Because of this behavior, it can be used as a way to + implement more complex logic around updates. For instance + allowing only the original creator of the object to update it. + """ + pass + + def post_update(self, item): + """ + Override this, will be called after update + """ + pass + + def pre_add(self, item): + """ + Override this, will be called before add. + If an exception is raised by this method, + the message is shown to the user and the add operation is aborted. + """ + pass + + def post_add(self, item): + """ + Override this, will be called after update + """ + pass + + def pre_delete(self, item): + """ + Override this, will be called before delete + If an exception is raised by this method, + the message is shown to the user and the delete operation is + aborted. Because of this behavior, it can be used as a way to + implement more complex logic around deletes. For instance + allowing only the original creator of the object to delete it. + """ + pass + + def post_delete(self, item): + """ + Override this, will be called after delete + """ + pass diff --git a/flask_appbuilder/babel/manager.py b/flask_appbuilder/babel/manager.py index 1af2bfb79b..b3403da0d2 100644 --- a/flask_appbuilder/babel/manager.py +++ b/flask_appbuilder/babel/manager.py @@ -1,5 +1,5 @@ import os -from flask import session, has_request_context +from flask import session, has_request_context, request from flask_babel import Babel from ..basemanager import BaseManager from .views import LocaleView @@ -35,6 +35,10 @@ def babel_default_locale(self): def get_locale(self): if has_request_context(): + # locale selector for API searches for request args + for arg, value in request.args.items(): + if arg=="_l_": + return value locale = session.get('locale') if locale: return locale diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index 3444c07edc..4a29f6d111 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -1,6 +1,7 @@ import logging from flask import Blueprint, url_for, current_app +from flask_marshmallow import Marshmallow from .views import IndexView, UtilView from .filters import TemplateFilters from .menu import Menu @@ -132,6 +133,8 @@ def __init__(self, app=None, self.update_perms = update_perms self.app = app + + self.marshmallow = Marshmallow() if app is not None: self.init_app(app, session) @@ -162,6 +165,7 @@ def init_app(self, app, session): self._add_admin_views() self._add_addon_views() self._add_menu_permissions() + self.marshmallow.init_app(self.app) if not self.app: for baseview in self.baseviews: # instantiate the views and add session diff --git a/flask_appbuilder/baseapp.py b/flask_appbuilder/baseapp.py deleted file mode 100644 index ca94169ba2..0000000000 --- a/flask_appbuilder/baseapp.py +++ /dev/null @@ -1,6 +0,0 @@ -from .base import AppBuilder - -""" - This is for retro compatibility -""" -BaseApp = AppBuilder diff --git a/flask_appbuilder/models/filters.py b/flask_appbuilder/models/filters.py index d533fa9e84..b5d2486d3c 100644 --- a/flask_appbuilder/models/filters.py +++ b/flask_appbuilder/models/filters.py @@ -4,6 +4,9 @@ log = logging.getLogger(__name__) +map_args_filter = {} +""" private map for arg_name and child Filter classes """ + class BaseFilter(object): """ @@ -20,6 +23,14 @@ class BaseFilter(object): If true this filter was not set by the user """ + arg_name = None + """ + the request argument that represent the filter + child Filter classes should set it to enable + REST API use + """ + + def __init__(self, column_name, datamodel, is_related_view=False): """ Constructor. @@ -35,6 +46,8 @@ def __init__(self, column_name, datamodel, is_related_view=False): self.datamodel = datamodel self.model = datamodel.obj self.is_related_view = is_related_view + if self.arg_name: + map_args_filter[self.arg_name] = self.__class__ def apply(self, query, value): """ @@ -108,12 +121,12 @@ def __init__(self, filter_converter, datamodel, search_columns=None): :param search_columns: restricts possible columns, accepts a list of column names :param datamodel: Accepts BaseInterface class """ - search_columns = search_columns or [] + self.search_columns = search_columns or [] self.filter_converter = filter_converter self.datamodel = datamodel self.clear_filters() - if search_columns: - self._search_filters = self._get_filters(search_columns) + if self.search_columns: + self._search_filters = self._get_filters(self.search_columns) self._all_filters = self._get_filters(datamodel.get_columns_list()) def get_search_filters(self): @@ -138,6 +151,20 @@ def _add_filter(self, filter_instance, value): def add_filter_index(self, column_name, filter_instance_index, value): self._add_filter(self._all_filters[column_name][filter_instance_index], value) + def rest_add_filters(self, data): + """ + Adds list of dicts + + :param data: list of dicts + :return: + """ + for _filter in data: + filter_class = map_args_filter.get(_filter['operator'], None) + print("OPR {}".format(filter_class)) + if filter_class: + self.add_filter(_filter['col_name'], filter_class, + _filter['value']) + def add_filter(self, column_name, filter_class, value): self._add_filter(filter_class(column_name, self.datamodel), value) return self @@ -208,7 +235,7 @@ def apply_all(self, query): return query def __repr__(self): - retstr = "FILTERS \n" + retstr = "FILTERS:" for flt, value in self.get_filters_values(): retstr = retstr + "%s.%s:%s\n" % (flt.model.__table__, str(flt.column_name), str(value)) return retstr diff --git a/flask_appbuilder/models/sqla/filters.py b/flask_appbuilder/models/sqla/filters.py index 091322a3c0..c22694058d 100644 --- a/flask_appbuilder/models/sqla/filters.py +++ b/flask_appbuilder/models/sqla/filters.py @@ -2,7 +2,7 @@ import datetime from dateutil import parser from flask_babel import lazy_gettext -from ..filters import BaseFilter, FilterRelation, BaseFilterConverter +from ..filters import BaseFilter, FilterRelation, BaseFilterConverter, map_args_filter log = logging.getLogger(__name__) @@ -58,6 +58,7 @@ def set_value_to_type(datamodel, column_name, value): class FilterStartsWith(BaseFilter): name = lazy_gettext('Starts with') + arg_name = 'sw' def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -66,6 +67,7 @@ def apply(self, query, value): class FilterNotStartsWith(BaseFilter): name = lazy_gettext('Not Starts with') + arg_name = "nsw" def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -74,6 +76,7 @@ def apply(self, query, value): class FilterEndsWith(BaseFilter): name = lazy_gettext('Ends with') + arg_name = "ew" def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -82,7 +85,8 @@ def apply(self, query, value): class FilterNotEndsWith(BaseFilter): name = lazy_gettext('Not Ends with') - + arg_name = 'new' + def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) return query.filter(~field.like('%' + value)) @@ -90,6 +94,7 @@ def apply(self, query, value): class FilterContains(BaseFilter): name = lazy_gettext('Contains') + arg_name = 'ct' def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -98,6 +103,7 @@ def apply(self, query, value): class FilterNotContains(BaseFilter): name = lazy_gettext('Not Contains') + arg_name = 'nct' def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -106,6 +112,7 @@ def apply(self, query, value): class FilterEqual(BaseFilter): name = lazy_gettext('Equal to') + arg_name = 'eq' def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -115,6 +122,7 @@ def apply(self, query, value): class FilterNotEqual(BaseFilter): name = lazy_gettext('Not Equal to') + arg_name = 'neq' def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -124,6 +132,7 @@ def apply(self, query, value): class FilterGreater(BaseFilter): name = lazy_gettext('Greater than') + arg_name = 'gt' def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -133,6 +142,7 @@ def apply(self, query, value): class FilterSmaller(BaseFilter): name = lazy_gettext('Smaller than') + arg_name = 'lt' def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -142,6 +152,7 @@ def apply(self, query, value): class FilterRelationOneToManyEqual(FilterRelation): name = lazy_gettext('Relation') + arg_name = 'rel_o_m' def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -151,6 +162,7 @@ def apply(self, query, value): class FilterRelationOneToManyNotEqual(FilterRelation): name = lazy_gettext('No Relation') + arg_name = 'nrel_o_m' def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -160,6 +172,7 @@ def apply(self, query, value): class FilterRelationManyToManyEqual(FilterRelation): name = lazy_gettext('Relation as Many') + arg_name = 'rel_m_m' def apply(self, query, value): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -169,6 +182,7 @@ def apply(self, query, value): class FilterEqualFunction(BaseFilter): name = "Filter view with a function" + arg_name = 'eqf' def apply(self, query, func): query, field = get_field_setup_query(query, self.model, self.column_name) @@ -177,6 +191,7 @@ def apply(self, query, func): class FilterInFunction(BaseFilter): name = "Filter view where field is in a list returned by a function" + arg_name = 'inf' def apply(self, query, func): query, field = get_field_setup_query(query, self.model, self.column_name) diff --git a/flask_appbuilder/security/decorators.py b/flask_appbuilder/security/decorators.py index 135e8e9ed3..b51e41a102 100644 --- a/flask_appbuilder/security/decorators.py +++ b/flask_appbuilder/security/decorators.py @@ -7,7 +7,7 @@ log = logging.getLogger(__name__) - + def has_access(f): """ Use this decorator to enable granular security permissions to your methods. diff --git a/flask_appbuilder/version.py b/flask_appbuilder/version.py index 6753a2d626..91731227d0 100644 --- a/flask_appbuilder/version.py +++ b/flask_appbuilder/version.py @@ -1,6 +1,6 @@ -VERSION_MAJOR = 1 -VERSION_MINOR = 12 -VERSION_BUILD = 2 +VERSION_MAJOR = 2 +VERSION_MINOR = 0 +VERSION_BUILD = 0 VERSION_INFO = (VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD) VERSION_STRING = "%d.%d.%d" % VERSION_INFO diff --git a/rtd_requirements.txt b/rtd_requirements.txt index 99b5014998..d4e192771a 100644 --- a/rtd_requirements.txt +++ b/rtd_requirements.txt @@ -8,3 +8,6 @@ Flask-Login>=0.3,<0.5 Flask-OpenID>=1.2.5,<2 Flask-SQLAlchemy>=2.3,<3 Flask-WTF>=0.14.2,<1 +flask-marshmallow==0.9.0 +marshmallow==2.18.0 +marshmallow-sqlalchemy==0.15.0 diff --git a/setup.py b/setup.py index 90a92c2edb..dc0da447f8 100644 --- a/setup.py +++ b/setup.py @@ -44,6 +44,9 @@ def desc(): 'Flask-SQLAlchemy>=2.3,<3', 'Flask-WTF>=0.14.2,<1', 'python-dateutil>=2.3,<3', + 'flask-marshmallow==0.9.0', + 'marshmallow==2.18.0', + 'marshmallow-sqlalchemy==0.15.0' ], tests_require=[ 'nose>=1.0', From ce81aadb2b68c588af0e7dd26dd530ac81b63d0e Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sun, 10 Feb 2019 12:38:49 +0000 Subject: [PATCH 002/109] New, example with React app that renders any ModelApi table --- examples/quickhowto-react/README.md | 4 ++ examples/quickhowto-react/package.json | 31 ++++++++ examples/quickhowto-react/public/favicon.ico | Bin 0 -> 3870 bytes examples/quickhowto-react/public/index.html | 13 ++++ .../quickhowto-react/public/manifest.json | 15 ++++ examples/quickhowto-react/src/Api.js | 2 + examples/quickhowto-react/src/App.js | 23 ++++++ examples/quickhowto-react/src/Table.js | 67 ++++++++++++++++++ examples/quickhowto-react/src/index.js | 5 ++ examples/quickhowto/app/views.py | 10 ++- 10 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 examples/quickhowto-react/README.md create mode 100644 examples/quickhowto-react/package.json create mode 100644 examples/quickhowto-react/public/favicon.ico create mode 100644 examples/quickhowto-react/public/index.html create mode 100644 examples/quickhowto-react/public/manifest.json create mode 100644 examples/quickhowto-react/src/Api.js create mode 100644 examples/quickhowto-react/src/App.js create mode 100644 examples/quickhowto-react/src/Table.js create mode 100644 examples/quickhowto-react/src/index.js diff --git a/examples/quickhowto-react/README.md b/examples/quickhowto-react/README.md new file mode 100644 index 0000000000..de99bde051 --- /dev/null +++ b/examples/quickhowto-react/README.md @@ -0,0 +1,4 @@ +Use this React app with the quickhowto example. +quickhowto provides the REST api that the React app will consume + + diff --git a/examples/quickhowto-react/package.json b/examples/quickhowto-react/package.json new file mode 100644 index 0000000000..5df6e6cfea --- /dev/null +++ b/examples/quickhowto-react/package.json @@ -0,0 +1,31 @@ +{ + "name": "react-tutorial", + "version": "1.0.0", + "private": true, + "dependencies": { + "axios": "^0.18.0", + "react": "^16.4.1", + "react-dom": "^16.4.1", + "react-scripts": "^2.1.1", + "react-router-dom": "^4.3.1", + "bootstrap": "^4.2.1" + }, + "homepage": "https://taniarascia.github.io/react-tutorial", + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject", + "predeploy": "npm run build", + "deploy": "gh-pages -d build" + }, + "devDependencies": { + "gh-pages": "^1.2.0" + }, + "browserslist": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all" + ] +} diff --git a/examples/quickhowto-react/public/favicon.ico b/examples/quickhowto-react/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 zcma);c{J4h9>;%nil|2-o+rCuEF-(I%-F}ijC~o(k~HKAkr0)!FCj~d>`RtpD?8b; zXOC1OD!V*IsqUwzbMF1)-gEDD=A573Z-&G7^LoAC9|WO7Xc0Cx1g^Zu0u_SjAPB3vGa^W|sj)80f#V0@M_CAZTIO(t--xg= z!sii`1giyH7EKL_+Wi0ab<)&E_0KD!3Rp2^HNB*K2@PHCs4PWSA32*-^7d{9nH2_E zmC{C*N*)(vEF1_aMamw2A{ZH5aIDqiabnFdJ|y0%aS|64E$`s2ccV~3lR!u<){eS` z#^Mx6o(iP1Ix%4dv`t@!&Za-K@mTm#vadc{0aWDV*_%EiGK7qMC_(`exc>-$Gb9~W!w_^{*pYRm~G zBN{nA;cm^w$VWg1O^^<6vY`1XCD|s_zv*g*5&V#wv&s#h$xlUilPe4U@I&UXZbL z0)%9Uj&@yd03n;!7do+bfixH^FeZ-Ema}s;DQX2gY+7g0s(9;`8GyvPY1*vxiF&|w z>!vA~GA<~JUqH}d;DfBSi^IT*#lrzXl$fNpq0_T1tA+`A$1?(gLb?e#0>UELvljtQ zK+*74m0jn&)5yk8mLBv;=@}c{t0ztT<v;Avck$S6D`Z)^c0(jiwKhQsn|LDRY&w(Fmi91I7H6S;b0XM{e zXp0~(T@k_r-!jkLwd1_Vre^v$G4|kh4}=Gi?$AaJ)3I+^m|Zyj#*?Kp@w(lQdJZf4 z#|IJW5z+S^e9@(6hW6N~{pj8|NO*>1)E=%?nNUAkmv~OY&ZV;m-%?pQ_11)hAr0oAwILrlsGawpxx4D43J&K=n+p3WLnlDsQ$b(9+4 z?mO^hmV^F8MV{4Lx>(Q=aHhQ1){0d*(e&s%G=i5rq3;t{JC zmgbn5Nkl)t@fPH$v;af26lyhH!k+#}_&aBK4baYPbZy$5aFx4}ka&qxl z$=Rh$W;U)>-=S-0=?7FH9dUAd2(q#4TCAHky!$^~;Dz^j|8_wuKc*YzfdAht@Q&ror?91Dm!N03=4=O!a)I*0q~p0g$Fm$pmr$ zb;wD;STDIi$@M%y1>p&_>%?UP($15gou_ue1u0!4(%81;qcIW8NyxFEvXpiJ|H4wz z*mFT(qVx1FKufG11hByuX%lPk4t#WZ{>8ka2efjY`~;AL6vWyQKpJun2nRiZYDij$ zP>4jQXPaP$UC$yIVgGa)jDV;F0l^n(V=HMRB5)20V7&r$jmk{UUIe zVjKroK}JAbD>B`2cwNQ&GDLx8{pg`7hbA~grk|W6LgiZ`8y`{Iq0i>t!3p2}MS6S+ zO_ruKyAElt)rdS>CtF7j{&6rP-#c=7evGMt7B6`7HG|-(WL`bDUAjyn+k$mx$CH;q2Dz4x;cPP$hW=`pFfLO)!jaCL@V2+F)So3}vg|%O*^T1j>C2lx zsURO-zIJC$^$g2byVbRIo^w>UxK}74^TqUiRR#7s_X$e)$6iYG1(PcW7un-va-S&u zHk9-6Zn&>T==A)lM^D~bk{&rFzCi35>UR!ZjQkdSiNX*-;l4z9j*7|q`TBl~Au`5& z+c)*8?#-tgUR$Zd%Q3bs96w6k7q@#tUn`5rj+r@_sAVVLqco|6O{ILX&U-&-cbVa3 zY?ngHR@%l{;`ri%H*0EhBWrGjv!LE4db?HEWb5mu*t@{kv|XwK8?npOshmzf=vZA@ zVSN9sL~!sn?r(AK)Q7Jk2(|M67Uy3I{eRy z_l&Y@A>;vjkWN5I2xvFFTLX0i+`{qz7C_@bo`ZUzDugfq4+>a3?1v%)O+YTd6@Ul7 zAfLfm=nhZ`)P~&v90$&UcF+yXm9sq!qCx3^9gzIcO|Y(js^Fj)Rvq>nQAHI92ap=P z10A4@prk+AGWCb`2)dQYFuR$|H6iDE8p}9a?#nV2}LBCoCf(Xi2@szia7#gY>b|l!-U`c}@ zLdhvQjc!BdLJvYvzzzngnw51yRYCqh4}$oRCy-z|v3Hc*d|?^Wj=l~18*E~*cR_kU z{XsxM1i{V*4GujHQ3DBpl2w4FgFR48Nma@HPgnyKoIEY-MqmMeY=I<%oG~l!f<+FN z1ZY^;10j4M4#HYXP zw5eJpA_y(>uLQ~OucgxDLuf}fVs272FaMxhn4xnDGIyLXnw>Xsd^J8XhcWIwIoQ9} z%FoSJTAGW(SRGwJwb=@pY7r$uQRK3Zd~XbxU)ts!4XsJrCycrWSI?e!IqwqIR8+Jh zlRjZ`UO1I!BtJR_2~7AbkbSm%XQqxEPkz6BTGWx8e}nQ=w7bZ|eVP4?*Tb!$(R)iC z9)&%bS*u(lXqzitAN)Oo=&Ytn>%Hzjc<5liuPi>zC_nw;Z0AE3Y$Jao_Q90R-gl~5 z_xAb2J%eArrC1CN4G$}-zVvCqF1;H;abAu6G*+PDHSYFx@Tdbfox*uEd3}BUyYY-l zTfEsOqsi#f9^FoLO;ChK<554qkri&Av~SIM*{fEYRE?vH7pTAOmu2pz3X?Wn*!ROX ztd54huAk&mFBemMooL33RV-*1f0Q3_(7hl$<#*|WF9P!;r;4_+X~k~uKEqdzZ$5Al zV63XN@)j$FN#cCD;ek1R#l zv%pGrhB~KWgoCj%GT?%{@@o(AJGt*PG#l3i>lhmb_twKH^EYvacVY-6bsCl5*^~L0 zonm@lk2UvvTKr2RS%}T>^~EYqdL1q4nD%0n&Xqr^cK^`J5W;lRRB^R-O8b&HENO||mo0xaD+S=I8RTlIfVgqN@SXDr2&-)we--K7w= zJVU8?Z+7k9dy;s;^gDkQa`0nz6N{T?(A&Iz)2!DEecLyRa&FI!id#5Z7B*O2=PsR0 zEvc|8{NS^)!d)MDX(97Xw}m&kEO@5jqRaDZ!+%`wYOI<23q|&js`&o4xvjP7D_xv@ z5hEwpsp{HezI9!~6O{~)lLR@oF7?J7i>1|5a~UuoN=q&6N}EJPV_GD`&M*v8Y`^2j zKII*d_@Fi$+i*YEW+Hbzn{iQk~yP z>7N{S4)r*!NwQ`(qcN#8SRQsNK6>{)X12nbF`*7#ecO7I)Q$uZsV+xS4E7aUn+U(K baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/examples/quickhowto-react/public/index.html b/examples/quickhowto-react/public/index.html new file mode 100644 index 0000000000..90e879fb1d --- /dev/null +++ b/examples/quickhowto-react/public/index.html @@ -0,0 +1,13 @@ + + + + + + React App + + + +
+ + diff --git a/examples/quickhowto-react/public/manifest.json b/examples/quickhowto-react/public/manifest.json new file mode 100644 index 0000000000..ef19ec243e --- /dev/null +++ b/examples/quickhowto-react/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": "./index.html", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/quickhowto-react/src/Api.js b/examples/quickhowto-react/src/Api.js new file mode 100644 index 0000000000..e87bd9a26d --- /dev/null +++ b/examples/quickhowto-react/src/Api.js @@ -0,0 +1,2 @@ +import React, { Component } from 'react'; + diff --git a/examples/quickhowto-react/src/App.js b/examples/quickhowto-react/src/App.js new file mode 100644 index 0000000000..c9763067b8 --- /dev/null +++ b/examples/quickhowto-react/src/App.js @@ -0,0 +1,23 @@ +import React, { Component } from 'react'; +import '../node_modules/bootstrap/dist/css/bootstrap.min.css'; +import Table from './Table'; +import axios from 'axios'; + + +class App extends Component { + + + render() { + return ( +
+

React FAB2 Experiment

+ +
+
+ ); + } +} + +export default App; diff --git a/examples/quickhowto-react/src/Table.js b/examples/quickhowto-react/src/Table.js new file mode 100644 index 0000000000..f127dec018 --- /dev/null +++ b/examples/quickhowto-react/src/Table.js @@ -0,0 +1,67 @@ +import React, { Component } from 'react'; +import axios from 'axios'; + +class TableHeader extends Component { + + render() { + const row = this.props.listColumns.map(key => + {this.props.labelColumns[key]}) + return ({row}); + } +} + +class TableRow extends Component { + + render () { + const row = this.props.listColumns.map(key => + {this.props.obj[key]}) + return ( + {row} + ); + } +} + +class Table extends Component { + + constructor(props) { + super(props); + this.state = {data: [], + listColumns: [], + labelColumns: []}; + } + + componentDidMount(){ + axios.get('http://localhost:8080/api/v1/' + this.props.resource + '/') + .then(response => { + this.setState({ data: response.data.result, + listColumns: response.data.list_columns, + labelColumns: response.data.label_columns}); + }) + .catch(function (error) { + console.log(error); + }) + } + + rows(){ + const listColumns = this.state.listColumns + return this.state.data.map(function(object, i){ + return ; + }); + } + + render() { + return ( + + + + {this.rows()} + +
+ ); + } +} + +export default Table; \ No newline at end of file diff --git a/examples/quickhowto-react/src/index.js b/examples/quickhowto-react/src/index.js new file mode 100644 index 0000000000..dbe51b5a81 --- /dev/null +++ b/examples/quickhowto-react/src/index.js @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render(, document.getElementById('root')); \ No newline at end of file diff --git a/examples/quickhowto/app/views.py b/examples/quickhowto/app/views.py index abca7785cf..93de7e64ab 100644 --- a/examples/quickhowto/app/views.py +++ b/examples/quickhowto/app/views.py @@ -7,7 +7,7 @@ from flask_appbuilder.api import ModelApi from flask_appbuilder.security.sqla.models import User -from app import db, appbuilder +from app import db, appbuilder, app from .models import ContactGroup, Gender, Contact, ContactGroupSchema, ContactSchema @@ -121,3 +121,11 @@ class ContactTimeChartView(GroupByChartView): appbuilder.add_separator("Contacts") appbuilder.add_view(ContactChartView, "Contacts Chart", icon="fa-dashboard", category="Contacts") appbuilder.add_view(ContactTimeChartView, "Contacts Birth Chart", icon="fa-dashboard", category="Contacts") + +@app.after_request +def after_request(response): + response.headers.add('Access-Control-Allow-Origin', '*') + response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') + response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') + return response + From f767da418b1ed4cf8f966176719d9cfb35811871 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sun, 10 Feb 2019 19:30:28 +0000 Subject: [PATCH 003/109] Fix, typo on docs --- examples/quickhowto/app/views.py | 2 +- flask_appbuilder/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/quickhowto/app/views.py b/examples/quickhowto/app/views.py index 93de7e64ab..dbcce7da99 100644 --- a/examples/quickhowto/app/views.py +++ b/examples/quickhowto/app/views.py @@ -60,11 +60,11 @@ class ContactModelApi(ModelApi): datamodel = SQLAInterface(Contact) #show_columns = ['name'] #list_model_schema = ContactSchema() + list_columns = ['name', 'address', 'personal_celphone'] class UserModelApi(ModelApi): datamodel = SQLAInterface(User) - class ContactChartView(GroupByChartView): datamodel = SQLAInterface(Contact) chart_title = 'Grouped contacts' diff --git a/flask_appbuilder/views.py b/flask_appbuilder/views.py index 1d783e0265..0216f5ac9c 100644 --- a/flask_appbuilder/views.py +++ b/flask_appbuilder/views.py @@ -444,7 +444,7 @@ class ModelView(RestCRUDView): delete, show, and list from your database tables, inherit your views from this class. Notice that this class inherits from BaseCRUDView and BaseModelView - so all properties from the parent class can be overriden. + so all properties from the parent class can be overridden. """ def __init__(self, **kwargs): From 679b48346b77c69f8f49093981d0156e1e658011 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sun, 10 Feb 2019 22:34:12 +0000 Subject: [PATCH 004/109] Fix, better app layout --- examples/quickhowto-react/src/Api.js | 2 -- examples/quickhowto-react/src/App.js | 2 +- examples/quickhowto-react/src/api/Api.js | 18 ++++++++++++++++++ .../src/{ => components}/Table.js | 7 +++++-- 4 files changed, 24 insertions(+), 5 deletions(-) delete mode 100644 examples/quickhowto-react/src/Api.js create mode 100644 examples/quickhowto-react/src/api/Api.js rename examples/quickhowto-react/src/{ => components}/Table.js (91%) diff --git a/examples/quickhowto-react/src/Api.js b/examples/quickhowto-react/src/Api.js deleted file mode 100644 index e87bd9a26d..0000000000 --- a/examples/quickhowto-react/src/Api.js +++ /dev/null @@ -1,2 +0,0 @@ -import React, { Component } from 'react'; - diff --git a/examples/quickhowto-react/src/App.js b/examples/quickhowto-react/src/App.js index c9763067b8..814fa409c5 100644 --- a/examples/quickhowto-react/src/App.js +++ b/examples/quickhowto-react/src/App.js @@ -1,6 +1,6 @@ import React, { Component } from 'react'; import '../node_modules/bootstrap/dist/css/bootstrap.min.css'; -import Table from './Table'; +import Table from './components/Table'; import axios from 'axios'; diff --git a/examples/quickhowto-react/src/api/Api.js b/examples/quickhowto-react/src/api/Api.js new file mode 100644 index 0000000000..4a3aa2cd04 --- /dev/null +++ b/examples/quickhowto-react/src/api/Api.js @@ -0,0 +1,18 @@ +import axios from 'axios' + + +const apiVersion = 'v1'; +const apiUrl = `http://localhost:8080/api/${apiVersion}`; + +class Api { + + constructor(height, width) { + this.client = axios.create({baseURL: apiUrl}); + } + + get(resource, filters=[], order={}, pageSize, page) { + return this.client.get(resource) + } +} + +export default Api diff --git a/examples/quickhowto-react/src/Table.js b/examples/quickhowto-react/src/components/Table.js similarity index 91% rename from examples/quickhowto-react/src/Table.js rename to examples/quickhowto-react/src/components/Table.js index f127dec018..fed2c8fa14 100644 --- a/examples/quickhowto-react/src/Table.js +++ b/examples/quickhowto-react/src/components/Table.js @@ -1,5 +1,7 @@ import React, { Component } from 'react'; -import axios from 'axios'; +import Api from '../api/Api' +//import axios from 'axios'; + class TableHeader extends Component { @@ -25,13 +27,14 @@ class Table extends Component { constructor(props) { super(props); + this.api = new Api(); this.state = {data: [], listColumns: [], labelColumns: []}; } componentDidMount(){ - axios.get('http://localhost:8080/api/v1/' + this.props.resource + '/') + this.api.get(this.props.resource) .then(response => { this.setState({ data: response.data.result, listColumns: response.data.list_columns, From 0931d5f2110078ad81064d2b82f93388bc37ee5c Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sun, 10 Feb 2019 23:06:00 +0000 Subject: [PATCH 005/109] New, small style improvements --- examples/quickhowto-react/package.json | 1 + examples/quickhowto-react/src/components/Table.js | 15 +++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/examples/quickhowto-react/package.json b/examples/quickhowto-react/package.json index 5df6e6cfea..14daad5a12 100644 --- a/examples/quickhowto-react/package.json +++ b/examples/quickhowto-react/package.json @@ -8,6 +8,7 @@ "react-dom": "^16.4.1", "react-scripts": "^2.1.1", "react-router-dom": "^4.3.1", + "react-icons": "^3.3.0", "bootstrap": "^4.2.1" }, "homepage": "https://taniarascia.github.io/react-tutorial", diff --git a/examples/quickhowto-react/src/components/Table.js b/examples/quickhowto-react/src/components/Table.js index fed2c8fa14..49bc787c56 100644 --- a/examples/quickhowto-react/src/components/Table.js +++ b/examples/quickhowto-react/src/components/Table.js @@ -1,14 +1,17 @@ import React, { Component } from 'react'; -import Api from '../api/Api' -//import axios from 'axios'; +import { FaAngleUp } from "react-icons/fa"; +import { IconContext } from "react-icons"; +import Api from '../api/Api'; class TableHeader extends Component { render() { const row = this.props.listColumns.map(key => - {this.props.labelColumns[key]}) - return ({row}); + {this.props.labelColumns[key]}) + return ( + {row} + ); } } @@ -54,7 +57,7 @@ class Table extends Component { render() { return ( - +
Date: Sat, 16 Feb 2019 17:10:25 +0000 Subject: [PATCH 006/109] New, test for the REST Api, begin, get item and get list --- flask_appbuilder/api.py | 1 + ...test_ldapsearch.py => _test_ldapsearch.py} | 0 flask_appbuilder/tests/sqla/__init__.py | 0 flask_appbuilder/tests/sqla/models.py | 67 +++++++++++ flask_appbuilder/tests/test_api.py | 105 ++++++++++++++++++ flask_appbuilder/tests/test_base.py | 81 ++------------ 6 files changed, 182 insertions(+), 72 deletions(-) rename flask_appbuilder/tests/{test_ldapsearch.py => _test_ldapsearch.py} (100%) create mode 100644 flask_appbuilder/tests/sqla/__init__.py create mode 100644 flask_appbuilder/tests/sqla/models.py create mode 100644 flask_appbuilder/tests/test_api.py diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 2283f43eaa..88e9adae72 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -603,6 +603,7 @@ def _get_list(self, filters=None, order_column=None, order_direction=None, label_columns=self._label_columns_json(), list_columns=self.list_columns, description_columns=self._description_columns_json(), + order_columns=self.order_columns, modelview_name=self.__class__.__name__, count=count, ids=pks, diff --git a/flask_appbuilder/tests/test_ldapsearch.py b/flask_appbuilder/tests/_test_ldapsearch.py similarity index 100% rename from flask_appbuilder/tests/test_ldapsearch.py rename to flask_appbuilder/tests/_test_ldapsearch.py diff --git a/flask_appbuilder/tests/sqla/__init__.py b/flask_appbuilder/tests/sqla/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flask_appbuilder/tests/sqla/models.py b/flask_appbuilder/tests/sqla/models.py new file mode 100644 index 0000000000..879f824d30 --- /dev/null +++ b/flask_appbuilder/tests/sqla/models.py @@ -0,0 +1,67 @@ +import string +from sqlalchemy import Column, Integer, String, ForeignKey, Date, Float, Enum, DateTime +from sqlalchemy.orm import relationship +from flask_appbuilder import Model, SQLA + +import enum + + +class Model1(Model): + id = Column(Integer, primary_key=True) + field_string = Column(String(50), unique=True, nullable=False) + field_integer = Column(Integer()) + field_float = Column(Float()) + field_date = Column(Date()) + + def __repr__(self): + return str(self.field_string) + + +class Model2(Model): + id = Column(Integer, primary_key=True) + field_string = Column(String(50), unique=True, nullable=False) + field_integer = Column(Integer()) + field_float = Column(Float()) + field_date = Column(Date()) + excluded_string = Column(String(50), default='EXCLUDED') + default_string = Column(String(50), default='DEFAULT') + group_id = Column(Integer, ForeignKey('model1.id'), nullable=False) + group = relationship("Model1") + + def __repr__(self): + return str(self.field_string) + + def field_method(self): + return "field_method_value" + +class Model3(Model): + pk1 = Column(Integer(), primary_key=True) + pk2 = Column(DateTime(), primary_key=True) + field_string = Column(String(50), unique=True, nullable=False) + + def __repr__(self): + return str(self.field_string) + +class TmpEnum(enum.Enum): + e1 = 'a' + e2 = 2 + +class ModelWithEnums(Model): + id = Column(Integer, primary_key=True) + enum1 = Column(Enum('e1', 'e2')) + enum2 = Column(Enum(TmpEnum)) + + + + """ --------------------------------- + TEST HELPER FUNCTIONS + --------------------------------- + """ + +def insert_data(session, Model1, count): + for i in range(count): + model = Model1(field_string="test{}".format(i), + field_integer=i, + field_float=float(i)) + session.add(model) + session.commit() diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py new file mode 100644 index 0000000000..9cb0e60577 --- /dev/null +++ b/flask_appbuilder/tests/test_api.py @@ -0,0 +1,105 @@ +import unittest +import os +import string +import random +import datetime +import json +import logging + +from nose.tools import eq_, ok_ + +log = logging.getLogger(__name__) +from flask_appbuilder import SQLA +from .sqla.models import Model1, insert_data + +MODEL1_DATA_SIZE = 10 + +class FlaskTestCase(unittest.TestCase): + def setUp(self): + from flask import Flask + from flask_appbuilder import AppBuilder + from flask_appbuilder.models.sqla.interface import SQLAInterface + from flask_appbuilder.api import ModelApi + + self.app = Flask(__name__) + self.basedir = os.path.abspath(os.path.dirname(__file__)) + self.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + self.app.config['CSRF_ENABLED'] = False + self.app.config['SECRET_KEY'] = 'thisismyscretkey' + self.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + self.db = SQLA(self.app) + self.appbuilder = AppBuilder(self.app, self.db.session) + + # Create models and insert data + insert_data(self.db.session, Model1, MODEL1_DATA_SIZE) + + class Model1Api(ModelApi): + datamodel = SQLAInterface(Model1) + list_columns = ['field_integer', 'field_float', 'field_string', 'field_date'] + description_columns = { + 'field_integer': 'Field Integer', + 'field_float': 'Field Float', + 'field_string': 'Field String' + } + + self.model1api = Model1Api + self.appbuilder.add_view_no_menu(Model1Api) + + + def tearDown(self): + self.appbuilder = None + self.app = None + self.db = None + + def test_get_item(self): + """ + REST Api: Test get item + """ + #insert_data(self.db.session, Model1) + client = self.app.test_client() + + # Check for Welcome Message + for i in range(1, MODEL1_DATA_SIZE): + rv = client.get('api/v1/model1api/{}/'.format(i)) + data = json.loads(rv.data.decode('utf-8')) + return self.assert_get_item(rv, data, i-1) + + def assert_get_item(self, rv, data, value): + # test result + log.info("get_item:{} {}".format(data, value)) + eq_(data['result'], {'field_date':None, + 'field_float':float(value), + 'field_integer':value, + 'field_string':"test{}".format(value)}) + # test descriptions + eq_(data['description_columns'], self.model1api.description_columns) + eq_(data['label_columns'], {'field_date':'Field Date', + 'field_float':'Field Float', + 'field_integer':'Field Integer', + 'field_string':'Field String', + 'id':'Id'}) + eq_(rv.status_code, 200) + + def test_get_list(self): + """ + REST Api: Test get list + """ + #insert_data(self.db.session, Model1) + client = self.app.test_client() + + # Check for Welcome Message + rv = client.get('api/v1/model1api/') + data = json.loads(rv.data.decode('utf-8')) + for i in range(1, MODEL1_DATA_SIZE): + return self.assert_get_list(rv, data['result'][i-1], i-1) + + def assert_get_list(self, rv, data, value): + # test result + + log.error("get list:{} {}".format(data, value)) + eq_(data, {'field_date':None, + 'field_float':float(value), + 'field_integer':value, + 'field_string':"test{}".format(value)}) + eq_(rv.status_code, 200) diff --git a/flask_appbuilder/tests/test_base.py b/flask_appbuilder/tests/test_base.py index afdbdf56ee..a778c93a75 100644 --- a/flask_appbuilder/tests/test_base.py +++ b/flask_appbuilder/tests/test_base.py @@ -5,35 +5,26 @@ import datetime import json import logging - -try: - import enum - _has_enum = True -except ImportError: - _has_enum = False - from nose.tools import eq_, ok_ -from sqlalchemy import Column, Integer, String, ForeignKey, Date, Float, Enum, DateTime -from sqlalchemy.orm import relationship from flask import redirect, request, session - -from flask_appbuilder import Model, SQLA +from flask_appbuilder import SQLA from flask_appbuilder.models.sqla.filters import FilterStartsWith, FilterEqual -from flask_appbuilder.models.mixins import FileColumn, ImageColumn from flask_appbuilder.views import MasterDetailView, CompactCRUDMixin from flask_appbuilder.charts.views import (ChartView, TimeChartView, DirectChartView, GroupByChartView, DirectByChartView) from flask_appbuilder.models.group import aggregate_avg, aggregate_count, aggregate_sum - from flask_appbuilder.models.generic import PSSession from flask_appbuilder.models.generic.interface import GenericInterface from flask_appbuilder.models.generic import PSModel +from .sqla.models import Model1, Model2, Model3, ModelWithEnums, TmpEnum + logging.basicConfig(format='%(asctime)s:%(levelname)s:%(name)s:%(message)s') logging.getLogger().setLevel(logging.DEBUG) + """ Constant english display string from framework """ @@ -49,55 +40,6 @@ log = logging.getLogger(__name__) -class Model1(Model): - id = Column(Integer, primary_key=True) - field_string = Column(String(50), unique=True, nullable=False) - field_integer = Column(Integer()) - field_float = Column(Float()) - field_date = Column(Date()) - field_file = FileColumn() - field_image = ImageColumn() - - def __repr__(self): - return str(self.field_string) - - -class Model2(Model): - id = Column(Integer, primary_key=True) - field_string = Column(String(50), unique=True, nullable=False) - field_integer = Column(Integer()) - field_float = Column(Float()) - field_date = Column(Date()) - excluded_string = Column(String(50), default='EXCLUDED') - default_string = Column(String(50), default='DEFAULT') - group_id = Column(Integer, ForeignKey('model1.id'), nullable=False) - group = relationship("Model1") - - def __repr__(self): - return str(self.field_string) - - def field_method(self): - return "field_method_value" - -class Model3(Model): - pk1 = Column(Integer(), primary_key=True) - pk2 = Column(DateTime(), primary_key=True) - field_string = Column(String(50), unique=True, nullable=False) - - def __repr__(self): - return str(self.field_string) - - -if _has_enum: - class TestEnum(enum.Enum): - e1 = 'a' - e2 = 2 - -class ModelWithEnums(Model): - id = Column(Integer, primary_key=True) - enum1 = Column(Enum('e1', 'e2')) - if _has_enum: - enum2 = Column(Enum(TestEnum)) class FlaskTestCase(unittest.TestCase): def setUp(self): @@ -112,6 +54,7 @@ def setUp(self): self.app.config['CSRF_ENABLED'] = False self.app.config['SECRET_KEY'] = 'thisismyscretkey' self.app.config['WTF_CSRF_ENABLED'] = False + self.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False self.db = SQLA(self.app) self.appbuilder = AppBuilder(self.app, self.db.session) @@ -528,27 +471,21 @@ def test_model_crud_with_enum(self): client = self.app.test_client() rv = self.login(client, DEFAULT_ADMIN_USER, DEFAULT_ADMIN_PASSWORD) - data = {'enum1': u'e1'} - if _has_enum: - data['enum2'] = 'e1' + data = {'enum1': u'e1', 'enum2': 'e1'} rv = client.post('/modelwithenumsview/add', data=data, follow_redirects=True) eq_(rv.status_code, 200) model = self.db.session.query(ModelWithEnums).first() eq_(model.enum1, u'e1') - if _has_enum: - eq_(model.enum2, TestEnum.e1) + eq_(model.enum2, TmpEnum.e1) - data = {'enum1': u'e2'} - if _has_enum: - data['enum2'] = 'e2' + data = {'enum1': u'e2', 'enum2': 'e2'} rv = client.post('/modelwithenumsview/edit/1', data=data, follow_redirects=True) eq_(rv.status_code, 200) model = self.db.session.query(ModelWithEnums).first() eq_(model.enum1, u'e2') - if _has_enum: - eq_(model.enum2, TestEnum.e2) + eq_(model.enum2, TmpEnum.e2) rv = client.get('/modelwithenumsview/delete/1', follow_redirects=True) eq_(rv.status_code, 200) From 374a83dc19ab9e044621db8f8baf4c6960199e6b Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sat, 16 Feb 2019 18:50:11 +0000 Subject: [PATCH 007/109] [tests] Fix, item loop tests are not being called --- flask_appbuilder/tests/test_api.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 9cb0e60577..34758bbd1c 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -15,6 +15,7 @@ MODEL1_DATA_SIZE = 10 class FlaskTestCase(unittest.TestCase): + def setUp(self): from flask import Flask from flask_appbuilder import AppBuilder @@ -56,24 +57,23 @@ def test_get_item(self): """ REST Api: Test get item """ - #insert_data(self.db.session, Model1) client = self.app.test_client() - # Check for Welcome Message for i in range(1, MODEL1_DATA_SIZE): rv = client.get('api/v1/model1api/{}/'.format(i)) data = json.loads(rv.data.decode('utf-8')) - return self.assert_get_item(rv, data, i-1) + self.assert_get_item(rv, data, i-1) def assert_get_item(self, rv, data, value): + log.info("assert_get_item: {} {}".format(data, value)) # test result - log.info("get_item:{} {}".format(data, value)) eq_(data['result'], {'field_date':None, 'field_float':float(value), 'field_integer':value, 'field_string':"test{}".format(value)}) # test descriptions eq_(data['description_columns'], self.model1api.description_columns) + # test labels eq_(data['label_columns'], {'field_date':'Field Date', 'field_float':'Field Float', 'field_integer':'Field Integer', @@ -85,21 +85,20 @@ def test_get_list(self): """ REST Api: Test get list """ - #insert_data(self.db.session, Model1) client = self.app.test_client() - # Check for Welcome Message rv = client.get('api/v1/model1api/') data = json.loads(rv.data.decode('utf-8')) for i in range(1, MODEL1_DATA_SIZE): - return self.assert_get_list(rv, data['result'][i-1], i-1) + self.assert_get_list(rv, data['result'][i-1], i-1) def assert_get_list(self, rv, data, value): + log.info("assert_get_list: {} {}".format(data, value)) # test result - - log.error("get list:{} {}".format(data, value)) eq_(data, {'field_date':None, 'field_float':float(value), 'field_integer':value, 'field_string':"test{}".format(value)}) eq_(rv.status_code, 200) + + From 52ddc1b73ff012e7a4637e8dc8ef3425a5c2074c Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sun, 17 Feb 2019 14:22:48 +0000 Subject: [PATCH 008/109] [tests] new, tests for arguments on lists and update, create with validation --- flask_appbuilder/api.py | 62 +------ flask_appbuilder/tests/test_api.py | 276 ++++++++++++++++++++++++++++- 2 files changed, 279 insertions(+), 59 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 88e9adae72..fc059713a9 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -598,7 +598,6 @@ def _get_list(self, filters=None, order_column=None, order_direction=None, count, lst = self.datamodel.query(self._filters, order_column, order_direction, page=page_index, page_size=page_size) pks = self.datamodel.get_keys(lst) - print("DESC {}".format(self._description_columns_json())) return self._api_json_response(200, label_columns=self._label_columns_json(), list_columns=self.list_columns, @@ -609,7 +608,11 @@ def _get_list(self, filters=None, order_column=None, order_direction=None, ids=pks, result=self.list_model_schema.dump(lst, many=True).data ) - + """ + ------------------------------------------------ + HELPER FUNCTIONS + ------------------------------------------------ + """ @staticmethod def _api_json_response(code, **kwargs): _ret_json = jsonify(kwargs) @@ -617,61 +620,6 @@ def _api_json_response(code, **kwargs): response.headers['Content-Type'] = "application/json; charset=utf-8" return response - """ - ------------------------------------------------ - HELPER FUNCTIONS - ------------------------------------------------ - """ - def show_item_dict(self, item): - """Returns a json-able dict for show""" - d = {} - for col in self.show_columns: - v = getattr(item, col) - if not isinstance(v, (int, float, string_types)): - v = str(v) - d[col] = v - return d - - - def _serialize_pk_if_composite(self, pk): - def date_serializer(obj): - if isinstance(obj, datetime): - return { - "_type": "datetime", - "value": obj.isoformat() - } - elif isinstance(obj, date): - return { - "_type": "date", - "value": obj.isoformat() - } - - if self.datamodel.is_pk_composite(): - try: - pk = json.dumps(pk, default=date_serializer) - except: - pass - return pk - - def _deserialize_pk_if_composite(self, pk): - def date_deserializer(obj): - if '_type' not in obj: - return obj - - from dateutil import parser - if obj['_type'] == 'datetime': - return parser.parse(obj['value']) - elif obj['_type'] == 'date': - return parser.parse(obj['value']).date() - return obj - - if self.datamodel.is_pk_composite(): - try: - pk = json.loads(pk, object_hook=date_deserializer) - except: - pass - return pk - def pre_update(self, item): """ Override this, this method is called before the update takes place. diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 34758bbd1c..c448ebf1f2 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -25,12 +25,11 @@ def setUp(self): self.app = Flask(__name__) self.basedir = os.path.abspath(os.path.dirname(__file__)) self.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' - self.app.config['CSRF_ENABLED'] = False self.app.config['SECRET_KEY'] = 'thisismyscretkey' self.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False self.db = SQLA(self.app) - self.appbuilder = AppBuilder(self.app, self.db.session) + self.appbuilder = AppBuilder(self.app, self.db.session, update_perms=False) # Create models and insert data insert_data(self.db.session, Model1, MODEL1_DATA_SIZE) @@ -62,6 +61,7 @@ def test_get_item(self): for i in range(1, MODEL1_DATA_SIZE): rv = client.get('api/v1/model1api/{}/'.format(i)) data = json.loads(rv.data.decode('utf-8')) + eq_(rv.status_code, 200) self.assert_get_item(rv, data, i-1) def assert_get_item(self, rv, data, value): @@ -81,6 +81,15 @@ def assert_get_item(self, rv, data, value): 'id':'Id'}) eq_(rv.status_code, 200) + def test_get_item_not_found(self): + """ + REST Api: Test get item not found + """ + client = self.app.test_client() + pk = 11 + rv = client.get('api/v1/model1api/{}/'.format(pk)) + eq_(rv.status_code, 404) + def test_get_list(self): """ REST Api: Test get list @@ -89,6 +98,10 @@ def test_get_list(self): rv = client.get('api/v1/model1api/') data = json.loads(rv.data.decode('utf-8')) + # Tests count property + eq_(data['count'], MODEL1_DATA_SIZE) + # Tests data result default page size + eq_(len(data['result']), self.model1api.page_size) for i in range(1, MODEL1_DATA_SIZE): self.assert_get_list(rv, data['result'][i-1], i-1) @@ -101,4 +114,263 @@ def assert_get_list(self, rv, data, value): 'field_string':"test{}".format(value)}) eq_(rv.status_code, 200) + def test_get_list_order(self): + """ + REST Api: Test get list order params + """ + client = self.app.test_client() + + # test string order asc + rv = client.get('api/v1/model1api/?_o_=field_string:asc') + data = json.loads(rv.data.decode('utf-8')) + eq_(data['result'][0], {'field_date':None, + 'field_float':0.0, + 'field_integer':0, + 'field_string':"test0"}) + eq_(rv.status_code, 200) + # test string order desc + rv = client.get('api/v1/model1api/?_o_=field_string:desc') + data = json.loads(rv.data.decode('utf-8')) + eq_(data['result'][0], {'field_date':None, + 'field_float':float(MODEL1_DATA_SIZE-1), + 'field_integer':MODEL1_DATA_SIZE-1, + 'field_string':"test{}".format(MODEL1_DATA_SIZE-1)}) + eq_(rv.status_code, 200) + + def test_get_list_page(self): + """ + REST Api: Test get list page params + """ + page_size = 5 + client = self.app.test_client() + + # test page zero + uri = 'api/v1/model1api/?_p_={}:0&_o_=field_integer:asc'.format(page_size) + rv = client.get(uri) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['result'][0], {'field_date':None, + 'field_float':0.0, + 'field_integer':0, + 'field_string':"test0"}) + eq_(rv.status_code, 200) + eq_(len(data['result']), page_size) + # test page zero + uri = 'api/v1/model1api/?_p_={}:1&_o_=field_integer:asc'.format(page_size) + rv = client.get(uri) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['result'][0], {'field_date':None, + 'field_float':float(page_size), + 'field_integer':page_size, + 'field_string':"test{}".format(page_size)}) + eq_(rv.status_code, 200) + eq_(len(data['result']), page_size) + + def test_get_list_filters(self): + """ + REST Api: Test get list filter params + """ + client = self.app.test_client() + filter_value = 5 + # test string order asc + uri = 'api/v1/model1api/?_f_0=field_integer:gt:{}&_o_=field_integer:asc'.format(filter_value) + rv = client.get(uri) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['result'][0], {'field_date':None, + 'field_float':float(filter_value + 1), + 'field_integer':filter_value + 1, + 'field_string':"test{}".format(filter_value + 1)}) + eq_(rv.status_code, 200) + + def test_info_filters(self): + """ + REST Api: Test info filters + """ + client = self.app.test_client() + uri = 'api/v1/model1api/info' + rv = client.get(uri) + data = json.loads(rv.data.decode('utf-8')) + expected_filters = { + 'field_date': [ + {'name': 'Equal to', 'operator': 'eq'}, + {'name': 'Greater than', 'operator': 'gt'}, + {'name': 'Smaller than', 'operator': 'lt'}, + {'name': 'Not Equal to', 'operator': 'neq'} + ], + 'field_float': [ + {'name': 'Equal to', 'operator': 'eq'}, + {'name': 'Greater than', 'operator': 'gt'}, + {'name': 'Smaller than', 'operator': 'lt'}, + {'name': 'Not Equal to', 'operator': 'neq'} + ], + 'field_integer': [ + {'name': 'Equal to', 'operator': 'eq'}, + {'name': 'Greater than', 'operator': 'gt'}, + {'name': 'Smaller than', 'operator': 'lt'}, + {'name': 'Not Equal to', 'operator': 'neq'} + ], + 'field_string': [ + {'name': 'Starts with', 'operator': 'sw'}, + {'name': 'Ends with', 'operator': 'ew'}, + {'name': 'Contains', 'operator': 'ct'}, + {'name': 'Equal to', 'operator': 'eq'}, + {'name': 'Not Starts with', 'operator': 'nsw'}, + {'name': 'Not Ends with', 'operator': 'new'}, + {'name': 'Not Contains', 'operator': 'nct'}, + {'name': 'Not Equal to', 'operator': 'neq'} + ] + } + eq_(data['filters'], expected_filters) + + def test_get_delete_item(self): + """ + REST Api: Test delete item + """ + client = self.app.test_client() + pk = 2 + rv = client.delete('api/v1/model1api/{}'.format(pk)) + eq_(rv.status_code, 200) + model = self.db.session.query(Model1).get(pk) + eq_(model, None) + + def test_get_delete_item_not_found(self): + """ + REST Api: Test delete item not found + """ + client = self.app.test_client() + pk = 11 + rv = client.delete('api/v1/model1api/{}'.format(pk)) + eq_(rv.status_code, 404) + + def test_get_update_item(self): + """ + REST Api: Test update item + """ + client = self.app.test_client() + pk = 3 + item = dict( + field_string="test_Put", + field_integer=0, + field_float=0.0 + ) + rv = client.put('api/v1/model1api/{}'.format(pk), json=item) + eq_(rv.status_code, 200) + model = self.db.session.query(Model1).get(pk) + eq_(model.field_string, "test_Put") + eq_(model.field_integer, 0) + eq_(model.field_float, 0.0) + + def test_get_update_item_not_found(self): + """ + REST Api: Test update item not found + """ + client = self.app.test_client() + pk = 11 + item = dict( + field_string="test_Put", + field_integer=0, + field_float=0.0 + ) + rv = client.put('api/v1/model1api/{}'.format(pk), json=item) + eq_(rv.status_code, 404) + + def test_get_update_val_size(self): + """ + REST Api: Test update validate size + """ + client = self.app.test_client() + pk = 1 + field_string = 'a' * 51 + item = dict( + field_string=field_string, + field_integer=11, + field_float=11.0 + ) + rv = client.put('api/v1/model1api/{}'.format(pk), json=item) + eq_(rv.status_code, 400) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['message']['field_string'][0], 'Longer than maximum length 50.') + def test_get_update_item_val_type(self): + """ + REST Api: Test update validate type + """ + client = self.app.test_client() + pk = 1 + item = dict( + field_string="test11", + field_integer="test11", + field_float=11.0 + ) + rv = client.put('api/v1/model1api/{}'.format(pk), json=item) + eq_(rv.status_code, 400) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['message']['field_integer'][0], 'Not a valid integer.') + + item = dict( + field_string=11, + field_integer=11, + field_float=11.0 + ) + rv = client.post('api/v1/model1api/', json=item) + eq_(rv.status_code, 400) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['message']['field_string'][0], 'Not a valid string.') + + def test_get_create_item(self): + """ + REST Api: Test create item + """ + client = self.app.test_client() + pk = 11 + item = dict( + field_string="test11", + field_integer=11, + field_float=11.0 + ) + rv = client.post('api/v1/model1api/', json=item) + eq_(rv.status_code, 200) + model = self.db.session.query(Model1).filter_by(field_string='test11').first() + eq_(model.field_string, "test11") + eq_(model.field_integer, 11) + eq_(model.field_float, 11.0) + + def test_get_create_item_val_size(self): + """ + REST Api: Test create validate size + """ + client = self.app.test_client() + field_string = 'a' * 51 + item = dict( + field_string=field_string, + field_integer=11, + field_float=11.0 + ) + rv = client.post('api/v1/model1api/', json=item) + eq_(rv.status_code, 400) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['message']['field_string'][0], 'Longer than maximum length 50.') + + def test_get_create_item_val_type(self): + """ + REST Api: Test create validate type + """ + client = self.app.test_client() + item = dict( + field_string="test11", + field_integer="test11", + field_float=11.0 + ) + rv = client.post('api/v1/model1api/', json=item) + eq_(rv.status_code, 400) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['message']['field_integer'][0], 'Not a valid integer.') + + item = dict( + field_string=11, + field_integer=11, + field_float=11.0 + ) + rv = client.post('api/v1/model1api/', json=item) + eq_(rv.status_code, 400) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['message']['field_string'][0], 'Not a valid string.') From 1f3b0b4058a380fd8fc46e67f703884b4ddd7ffa Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sun, 17 Feb 2019 15:44:04 +0000 Subject: [PATCH 009/109] Fix, make POST and PUT more compliant to rfc2616 --- flask_appbuilder/api.py | 28 ++++++++++++++-------------- flask_appbuilder/tests/test_api.py | 11 +++++++---- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index fc059713a9..c72c4eae60 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -520,17 +520,17 @@ def post(self): item = self.add_model_schema.load(request.json) except ValidationError as err: ret_code = 400 - message = err.messages + response = {'message': err.messages} else: self.pre_add(item.data) if self.datamodel.add(item.data): self.post_add(item.data) - ret_code = 200 - message = 'OK' + ret_code = 201 + response = {'result': self.add_model_schema.dump(item.data, many=False).data} else: ret_code = 500 - message = 'NOTOK' - return self._api_json_response(ret_code, message=message) + response = {'message': "Internal error"} + return self._api_json_response(ret_code, **response) @expose('/', methods=['PUT']) @permission_name('put') @@ -538,24 +538,24 @@ def put(self, pk): item = self.datamodel.get(pk) if not item: ret_code = 404 - message = 'Not found' + response = {'message': 'Not found'} else: try: item = self.edit_model_schema.load(request.json, instance=item) except ValidationError as err: ret_code = 400 - message = err.messages + response = {'message': err.messages} else: self.pre_update(item.data) if self.datamodel.edit(item.data): self.post_add(item) ret_code = 200 - message = 'OK' + response = {'result': self.edit_model_schema.dump(item.data, many=False).data} self.post_update(item) else: ret_code = 500 - message = 'NOTOK' - return self._api_json_response(ret_code, message=message) + response = {'message': "Internal error"} + return self._api_json_response(ret_code, **response) @expose('/', methods=['DELETE']) @permission_name('delete') @@ -563,17 +563,17 @@ def delete(self, pk): item = self.datamodel.get(pk, self._base_filters) if not item: ret_code = 404 - message = "Not found" + response = {'message': 'Not found'} else: self.pre_delete(item) if self.datamodel.delete(item): self.post_delete(item) ret_code = 200 - message = 'OK' + response = {'message': 'OK'} else: ret_code = 500 - message = 'NOTOK' - return self._api_json_response(ret_code, message=message) + response = {'message': "Internal error"} + return self._api_json_response(ret_code, **response) def _get_item(self, pk): item = self.datamodel.get(pk) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index c448ebf1f2..ea146cd21d 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -316,7 +316,7 @@ def test_get_update_item_val_type(self): data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Not a valid string.') - def test_get_create_item(self): + def test_create_item(self): """ REST Api: Test create item """ @@ -325,16 +325,19 @@ def test_get_create_item(self): item = dict( field_string="test11", field_integer=11, - field_float=11.0 + field_float=11.0, + field_date=None ) rv = client.post('api/v1/model1api/', json=item) - eq_(rv.status_code, 200) + data = json.loads(rv.data.decode('utf-8')) + eq_(rv.status_code, 201) + eq_(data['result'], item) model = self.db.session.query(Model1).filter_by(field_string='test11').first() eq_(model.field_string, "test11") eq_(model.field_integer, 11) eq_(model.field_float, 11.0) - def test_get_create_item_val_size(self): + def test_create_item_val_size(self): """ REST Api: Test create validate size """ From 67f99911edc1f0534c3aade7df9182bfcf0c633c Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sun, 17 Feb 2019 21:07:58 +0000 Subject: [PATCH 010/109] [style] Fix, line length compliance to PEP8 --- flask_appbuilder/api.py | 144 ++++++++++++++++++++++++++-------------- 1 file changed, 94 insertions(+), 50 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index c72c4eae60..77c78f5749 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -40,7 +40,9 @@ def wraps(self, *args, **kwargs): order_column, order_direction = '', '' kwargs['order_column'] = order_column kwargs['order_direction'] = order_direction - return f(self, *args, order_column=order_column, order_direction=order_direction) + return f(self, *args, + order_column=order_column, + order_direction=order_direction) return functools.update_wrapper(wraps, f) @@ -64,7 +66,8 @@ def wraps(self, *args, **kwargs): kwargs['page_index'] = int(_item_re_match[1]) return f(self, *args, **kwargs) except ValueError as e: - log.warn("Bad page args {}, {}".format(_item_re_match[0], _item_re_match[1])) + log.warn("Bad page args {}, {}".format(_item_re_match[0], + _item_re_match[1])) kwargs['page_size'] = self.page_size kwargs['page_index'] = 0 return f(self, *args, **kwargs) @@ -128,9 +131,11 @@ def wrap(f): class BaseApi: """ All apis inherit from this class. - it's constructor will register your exposed urls on flask as a Blueprint. + it's constructor will register your exposed urls on flask + as a Blueprint. - This class does not expose any urls, but provides a common base for all apis. + This class does not expose any urls, + but provides a common base for all apis. """ appbuilder = None @@ -154,7 +159,8 @@ def __init__(self): self.base_permissions = set() for attr_name in dir(self): if hasattr(getattr(self, attr_name), '_permission_name'): - permission_name = getattr(getattr(self, attr_name), '_permission_name') + permission_name = \ + getattr(getattr(self, attr_name), '_permission_name') self.base_permissions.add('can_' + permission_name) self.base_permissions = list(self.base_permissions) if not self.extra_args: @@ -175,7 +181,8 @@ def create_blueprint(self, appbuilder, if self.route_base is None: self.route_base = \ - "/api/{}/{}".format(self.version, self.__class__.__name__.lower()) + "/api/{}/{}".format(self.version, + self.__class__.__name__.lower()) self.blueprint = Blueprint(self.endpoint, __name__, url_prefix=self.route_base) @@ -239,21 +246,24 @@ class MyModelApi(BaseModelApi): """ search_columns = None """ - List with allowed search columns, if not provided all possible search columns will be used - If you want to limit the search (*filter*) columns possibilities, define it with a list of column names from your model:: + List with allowed search columns, if not provided all possible search + columns will be used. If you want to limit the search (*filter*) columns + possibilities, define it with a list of column names from your model:: class MyView(ModelView): datamodel = SQLAInterface(MyTable) - search_columns = ['name','address'] + search_columns = ['name', 'address'] """ search_exclude_columns = None """ - List with columns to exclude from search. Search includes all possible columns by default + List with columns to exclude from search. Search includes all possible + columns by default """ label_columns = None """ - Dictionary of labels for your columns, override this if you want different pretify labels + Dictionary of labels for your columns, override this if you want + different pretify labels example (will just override the label for name column):: @@ -280,7 +290,8 @@ class MyView(ModelApi): base_order = None """ - Use this property to set default ordering for lists ('col_name','asc|desc'):: + Use this property to set default ordering for lists + ('col_name','asc|desc'):: class MyView(ModelApi): datamodel = SQLAInterface(MyTable) @@ -290,7 +301,10 @@ class MyView(ModelApi): _base_filters = None """ Internal base Filter from class Filters will always filter view """ _filters = None - """ Filters object will calculate all possible filter types based on search_columns """ + """ + Filters object will calculate all possible filter types + based on search_columns + """ list_model_schema = None add_model_schema = None edit_model_schema = None @@ -339,11 +353,13 @@ def _init_properties(self): self.search_exclude_columns = self.search_exclude_columns or [] self.search_columns = self.search_columns or [] - self._base_filters = self.datamodel.get_filters().add_filter_list(self.base_filters) + self._base_filters = self.datamodel.get_filters()\ + .add_filter_list(self.base_filters) list_cols = self.datamodel.get_columns_list() search_columns = self.datamodel.get_search_columns_list() if not self.search_columns: - self.search_columns = [x for x in search_columns if x not in self.search_exclude_columns] + self.search_columns = \ + [x for x in search_columns if x not in self.search_exclude_columns] self._gen_labels_columns(list_cols) self._filters = self.datamodel.get_filters(self.search_columns) @@ -353,13 +369,25 @@ def _init_titles(self): class ModelApi(BaseModelApi): list_title = "" - """ List Title, if not configured the default is 'List ' with pretty model name """ + """ + List Title, if not configured the default is + 'List ' with pretty model name + """ show_title = "" - """ Show Title , if not configured the default is 'Show ' with pretty model name """ + """ + Show Title , if not configured the default is + 'Show ' with pretty model name + """ add_title = "" - """ Add Title , if not configured the default is 'Add ' with pretty model name """ + """ + Add Title , if not configured the default is + 'Add ' with pretty model name + """ edit_title = "" - """ Edit Title , if not configured the default is 'Edit ' with pretty model name """ + """ + Edit Title , if not configured the default is + 'Edit ' with pretty model name + """ list_columns = None """ @@ -373,25 +401,28 @@ class ModelApi(BaseModelApi): """ add_columns = None """ - A list of columns (or model's methods) to be displayed on the add form view. - Use it to control the order of the display + A list of columns (or model's methods) to be displayed + on the add form view. Use it to control the order of the display """ edit_columns = None """ - A list of columns (or model's methods) to be displayed on the edit form view. - Use it to control the order of the display + A list of columns (or model's methods) to be displayed + on the edit form view. Use it to control the order of the display """ show_exclude_columns = None """ - A list of columns to exclude from the show view. By default all columns are included. + A list of columns to exclude from the show view. + By default all columns are included. """ add_exclude_columns = None """ - A list of columns to exclude from the add form. By default all columns are included. + A list of columns to exclude from the add form. + By default all columns are included. """ edit_exclude_columns = None """ - A list of columns to exclude from the edit form. By default all columns are included. + A list of columns to exclude from the edit form. + By default all columns are included. """ order_columns = None """ Allowed order columns """ @@ -406,7 +437,8 @@ class ModelApi(BaseModelApi): class MyView(ModelView): datamodel = SQLAModel(MyTable, db.session) - description_columns = {'name':'your models name column','address':'the address column'} + description_columns = {'name':'your models name column', + 'address':'the address column'} """ formatters_columns = None """ Dictionary of formatter used to format the display of columns @@ -415,7 +447,8 @@ class MyView(ModelView): """ def create_blueprint(self, appbuilder, *args, **kwargs): self._init_model_schemas() - return super(ModelApi, self).create_blueprint(appbuilder, *args, **kwargs) + return super(ModelApi, self).create_blueprint(appbuilder, + *args, **kwargs) def _init_model_schemas(self): class ListMetaSchema(self.appbuilder.marshmallow.ModelSchema): @@ -479,18 +512,24 @@ def _init_properties(self): # Generate base props list_cols = self.datamodel.get_user_columns_list() if not self.list_columns and self.list_model_schema: - self.list_columns = list(self.list_model_schema._declared_fields.keys()) + self.list_columns =\ + list(self.list_model_schema._declared_fields.keys()) else: - self.list_columns = self.list_columns or self.datamodel.get_columns_list() + self.list_columns = self.list_columns or \ + self.datamodel.get_columns_list() self._gen_labels_columns(self.list_columns) - self.order_columns = self.order_columns or self.datamodel.get_order_columns_list(list_columns=self.list_columns) + self.order_columns = self.order_columns or \ + self.datamodel.get_order_columns_list(list_columns=self.list_columns) # Process excluded columns if not self.show_columns: - self.show_columns = [x for x in list_cols if x not in self.show_exclude_columns] + self.show_columns = \ + [x for x in list_cols if x not in self.show_exclude_columns] if not self.add_columns: - self.add_columns = [x for x in list_cols if x not in self.add_exclude_columns] + self.add_columns = \ + [x for x in list_cols if x not in self.add_exclude_columns] if not self.edit_columns: - self.edit_columns = [x for x in list_cols if x not in self.edit_exclude_columns] + self.edit_columns = \ + [x for x in list_cols if x not in self.edit_exclude_columns] self._filters = self.datamodel.get_filters(self.search_columns) @expose('/info', methods=['GET']) @@ -526,7 +565,9 @@ def post(self): if self.datamodel.add(item.data): self.post_add(item.data) ret_code = 201 - response = {'result': self.add_model_schema.dump(item.data, many=False).data} + response = \ + {'result': self.add_model_schema.dump(item.data, + many=False).data} else: ret_code = 500 response = {'message': "Internal error"} @@ -550,7 +591,9 @@ def put(self, pk): if self.datamodel.edit(item.data): self.post_add(item) ret_code = 200 - response = {'result': self.edit_model_schema.dump(item.data, many=False).data} + response = \ + {'result': self.edit_model_schema.dump(item.data, + many=False).data} self.post_update(item) else: ret_code = 500 @@ -580,11 +623,12 @@ def _get_item(self, pk): if not item: abort(404) return self._api_json_response(200, pk=pk, - label_columns=self._label_columns_json(), - include_columns=self.show_columns, - description_columns=self._description_columns_json(), - modelview_name=self.__class__.__name__, - result=self.show_model_schema.dump(item, many=False).data) + label_columns=self._label_columns_json(), + include_columns=self.show_columns, + description_columns=self._description_columns_json(), + modelview_name=self.__class__.__name__, + result=self.show_model_schema.dump(item, + many=False).data) @order_args @page_args @@ -599,15 +643,15 @@ def _get_list(self, filters=None, order_column=None, order_direction=None, page=page_index, page_size=page_size) pks = self.datamodel.get_keys(lst) return self._api_json_response(200, - label_columns=self._label_columns_json(), - list_columns=self.list_columns, - description_columns=self._description_columns_json(), - order_columns=self.order_columns, - modelview_name=self.__class__.__name__, - count=count, - ids=pks, - result=self.list_model_schema.dump(lst, many=True).data - ) + label_columns=self._label_columns_json(), + list_columns=self.list_columns, + description_columns=self._description_columns_json(), + order_columns=self.order_columns, + modelview_name=self.__class__.__name__, + count=count, + ids=pks, + result=self.list_model_schema.dump(lst, many=True).data + ) """ ------------------------------------------------ HELPER FUNCTIONS From ad95edd5811a97592f01b114fee5fbacf65bb86b Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sat, 23 Feb 2019 19:50:01 +0000 Subject: [PATCH 011/109] New, marshmallow model schema factory --- flask_appbuilder/api.py | 55 ++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 77c78f5749..6289512557 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -451,36 +451,32 @@ def create_blueprint(self, appbuilder, *args, **kwargs): *args, **kwargs) def _init_model_schemas(self): - class ListMetaSchema(self.appbuilder.marshmallow.ModelSchema): - class Meta: - model = self.datamodel.obj - fields = self.list_columns - strict = True - class AddMetaSchema(self.appbuilder.marshmallow.ModelSchema): - class Meta: - model = self.datamodel.obj - fields = self.add_columns - strict = True - class EditMetaSchema(self.appbuilder.marshmallow.ModelSchema): - class Meta: - model = self.datamodel.obj - fields = self.edit_columns - strict = True - class ShowMetaSchema(self.appbuilder.marshmallow.ModelSchema): - class Meta: - model = self.datamodel.obj - fields = self.show_columns - strict = True - # Create Marshmalow schemas if one is not specified if self.list_model_schema is None: - self.list_model_schema = ListMetaSchema() + self.list_model_schema = \ + self._model_schema_factory(self.list_columns) if self.add_model_schema is None: - self.add_model_schema = AddMetaSchema() + self.add_model_schema = \ + self._model_schema_factory(self.add_columns) if self.edit_model_schema is None: - self.edit_model_schema = EditMetaSchema() + self.edit_model_schema = \ + self._model_schema_factory(self.edit_columns) if self.show_model_schema is None: - self.show_model_schema = ShowMetaSchema() + self.show_model_schema = \ + self._model_schema_factory(self.show_columns) + + def _model_schema_factory(self, columns): + """ + Will create a Marshmallow SQLAlchemy schema class + :param columns: List with columns to include + :return: ModelSchema object + """ + class MetaSchema(self.appbuilder.marshmallow.ModelSchema): + class Meta: + model = self.datamodel.obj + fields = columns + strict = True + return MetaSchema() def _init_titles(self): """ @@ -638,9 +634,12 @@ def _get_list(self, filters=None, order_column=None, order_direction=None, self._filters.clear_filters() self._filters.rest_add_filters(filters) - # Make query - count, lst = self.datamodel.query(self._filters, order_column, order_direction, - page=page_index, page_size=page_size) + # Make the query + count, lst = self.datamodel.query(self._filters, + order_column, + order_direction, + page=page_index, + page_size=page_size) pks = self.datamodel.get_keys(lst) return self._api_json_response(200, label_columns=self._label_columns_json(), From 2f971e2275cc25f996bbf6fed44d9d1a3bdff248 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sun, 3 Mar 2019 16:13:23 +0000 Subject: [PATCH 012/109] [style] Fix, PEP8 compliance --- flask_appbuilder/api.py | 99 +++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 49 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 6289512557..0f7c45342f 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -1,20 +1,18 @@ import re -import json import logging import functools -from flask import Blueprint, session, flash, \ - render_template, url_for, abort, make_response, jsonify, request +from flask import Blueprint, abort, make_response, jsonify, request from flask_babel import lazy_gettext as _ -from datetime import datetime, date -from .security.decorators import has_access, permission_name, has_access_api +from .security.decorators import permission_name from marshmallow import ValidationError -from ._compat import as_unicode, string_types +from ._compat import as_unicode log = logging.getLogger(__name__) -URI_ORDER_BY_PREFIX="_o_" -URI_PAGE_PREFIX="_p_" -URI_FILTER_PREFIX="_f_" +URI_ORDER_BY_PREFIX = "_o_" +URI_PAGE_PREFIX = "_p_" +URI_FILTER_PREFIX = "_f_" + def order_args(f): """ @@ -66,8 +64,10 @@ def wraps(self, *args, **kwargs): kwargs['page_index'] = int(_item_re_match[1]) return f(self, *args, **kwargs) except ValueError as e: - log.warn("Bad page args {}, {}".format(_item_re_match[0], - _item_re_match[1])) + log.warning("Bad page args {}, {}".format( + _item_re_match[0], + _item_re_match[1]) + ) kwargs['page_size'] = self.page_size kwargs['page_index'] = 0 return f(self, *args, **kwargs) @@ -94,7 +94,6 @@ def wraps(self, *args, **kwargs): for arg, value in request.args.items(): key_match = re.match("{}(\d)".format(URI_FILTER_PREFIX), arg) if key_match: - key_index = key_match[0] re_match = re.findall('(.*):(.*):(.*)', value) for _item_re_match in re_match: if _item_re_match and len(_item_re_match) == 3: @@ -103,13 +102,12 @@ def wraps(self, *args, **kwargs): "value": _item_re_match[2], }) else: - log.warn("Bar filter args {} ".format(_item_re_match)) + log.warning("Bar filter args {} ".format(_item_re_match)) kwargs['filters'] = filters return f(self, *args, **kwargs) return functools.update_wrapper(wraps, f) - def expose(url='/', methods=('GET',)): """ Use this decorator to expose views on your view classes. @@ -159,9 +157,9 @@ def __init__(self): self.base_permissions = set() for attr_name in dir(self): if hasattr(getattr(self, attr_name), '_permission_name'): - permission_name = \ + _permission_name = \ getattr(getattr(self, attr_name), '_permission_name') - self.base_permissions.add('can_' + permission_name) + self.base_permissions.add('can_' + _permission_name) self.base_permissions = list(self.base_permissions) if not self.extra_args: self.extra_args = dict() @@ -184,12 +182,11 @@ def create_blueprint(self, appbuilder, "/api/{}/{}".format(self.version, self.__class__.__name__.lower()) self.blueprint = Blueprint(self.endpoint, __name__, - url_prefix=self.route_base) + url_prefix=self.route_base) self._register_urls() return self.blueprint - def _register_urls(self): for attr_name in dir(self): attr = getattr(self, attr_name) @@ -200,7 +197,8 @@ def _register_urls(self): attr, methods=methods) - def _prettify_name(self, name): + @staticmethod + def _prettify_name(name): """ Prettify pythonic variable name. @@ -211,7 +209,8 @@ def _prettify_name(self, name): """ return re.sub(r'(?<=.)([A-Z])', r' \1', name) - def _prettify_column(self, name): + @staticmethod + def _prettify_column(name): """ Prettify pythonic variable name. @@ -338,23 +337,13 @@ def _label_columns_json(self): ret[key] = as_unicode(_(value).encode('UTF-8')) return ret - def _description_columns_json(self): - """ - Prepares dict with col descriptions to be JSON serializable - """ - ret = {} - for key, value in list(self.description_columns.items()): - ret[key] = as_unicode(_(value).encode('UTF-8')) - return ret - def _init_properties(self): self.label_columns = self.label_columns or {} self.base_filters = self.base_filters or [] self.search_exclude_columns = self.search_exclude_columns or [] self.search_columns = self.search_columns or [] - self._base_filters = self.datamodel.get_filters()\ - .add_filter_list(self.base_filters) + self._base_filters = self.datamodel.get_filters().add_filter_list(self.base_filters) list_cols = self.datamodel.get_columns_list() search_columns = self.datamodel.get_search_columns_list() if not self.search_columns: @@ -367,6 +356,7 @@ def _init_properties(self): def _init_titles(self): pass + class ModelApi(BaseModelApi): list_title = "" """ @@ -536,7 +526,7 @@ def info(self): for col in self.search_columns: search_filters[col] = [ {'name': as_unicode(flt.name), - 'operator' : flt.arg_name} for flt in dict_filters[col] + 'operator': flt.arg_name} for flt in dict_filters[col] ] return self._api_json_response(200, filters=search_filters) @@ -618,13 +608,14 @@ def _get_item(self, pk): item = self.datamodel.get(pk) if not item: abort(404) - return self._api_json_response(200, pk=pk, - label_columns=self._label_columns_json(), - include_columns=self.show_columns, - description_columns=self._description_columns_json(), - modelview_name=self.__class__.__name__, - result=self.show_model_schema.dump(item, - many=False).data) + return self._api_json_response( + 200, pk=pk, + label_columns=self._label_columns_json(), + include_columns=self.show_columns, + description_columns=self._description_columns_json(), + modelview_name=self.__class__.__name__, + result=self.show_model_schema.dump(item, many=False).data + ) @order_args @page_args @@ -641,16 +632,17 @@ def _get_list(self, filters=None, order_column=None, order_direction=None, page=page_index, page_size=page_size) pks = self.datamodel.get_keys(lst) - return self._api_json_response(200, - label_columns=self._label_columns_json(), - list_columns=self.list_columns, - description_columns=self._description_columns_json(), - order_columns=self.order_columns, - modelview_name=self.__class__.__name__, - count=count, - ids=pks, - result=self.list_model_schema.dump(lst, many=True).data - ) + return self._api_json_response( + 200, + label_columns=self._label_columns_json(), + list_columns=self.list_columns, + description_columns=self._description_columns_json(), + order_columns=self.order_columns, + modelview_name=self.__class__.__name__, + count=count, + ids=pks, + result=self.list_model_schema.dump(lst, many=True).data + ) """ ------------------------------------------------ HELPER FUNCTIONS @@ -663,6 +655,15 @@ def _api_json_response(code, **kwargs): response.headers['Content-Type'] = "application/json; charset=utf-8" return response + def _description_columns_json(self): + """ + Prepares dict with col descriptions to be JSON serializable + """ + ret = {} + for key, value in list(self.description_columns.items()): + ret[key] = as_unicode(_(value).encode('UTF-8')) + return ret + def pre_update(self, item): """ Override this, this method is called before the update takes place. From e86b52b008e22e11f7643c34e90cb559934bf7c0 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 4 Mar 2019 22:22:25 +0000 Subject: [PATCH 013/109] [api] New, Test cols as functions --- flask_appbuilder/tests/sqla/models.py | 18 ++- flask_appbuilder/tests/test_api.py | 192 +++++++++++++++++--------- 2 files changed, 137 insertions(+), 73 deletions(-) diff --git a/flask_appbuilder/tests/sqla/models.py b/flask_appbuilder/tests/sqla/models.py index 879f824d30..2ab2d5a49d 100644 --- a/flask_appbuilder/tests/sqla/models.py +++ b/flask_appbuilder/tests/sqla/models.py @@ -1,4 +1,3 @@ -import string from sqlalchemy import Column, Integer, String, ForeignKey, Date, Float, Enum, DateTime from sqlalchemy.orm import relationship from flask_appbuilder import Model, SQLA @@ -16,6 +15,14 @@ class Model1(Model): def __repr__(self): return str(self.field_string) + def full_concat(self): + return "{}.{}.{}.{}".format( + self.field_string, + self.field_integer, + self.field_float, + self.field_date + ) + class Model2(Model): id = Column(Integer, primary_key=True) @@ -34,6 +41,7 @@ def __repr__(self): def field_method(self): return "field_method_value" + class Model3(Model): pk1 = Column(Integer(), primary_key=True) pk2 = Column(DateTime(), primary_key=True) @@ -42,25 +50,27 @@ class Model3(Model): def __repr__(self): return str(self.field_string) + class TmpEnum(enum.Enum): e1 = 'a' e2 = 2 + class ModelWithEnums(Model): id = Column(Integer, primary_key=True) enum1 = Column(Enum('e1', 'e2')) enum2 = Column(Enum(TmpEnum)) - """ --------------------------------- TEST HELPER FUNCTIONS --------------------------------- """ -def insert_data(session, Model1, count): + +def insert_data(session, model1, count): for i in range(count): - model = Model1(field_string="test{}".format(i), + model = model1(field_string="test{}".format(i), field_integer=i, field_float=float(i)) session.add(model) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index ea146cd21d..7d130a1326 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -14,6 +14,7 @@ MODEL1_DATA_SIZE = 10 + class FlaskTestCase(unittest.TestCase): def setUp(self): @@ -30,13 +31,32 @@ def setUp(self): self.db = SQLA(self.app) self.appbuilder = AppBuilder(self.app, self.db.session, update_perms=False) - # Create models and insert data insert_data(self.db.session, Model1, MODEL1_DATA_SIZE) class Model1Api(ModelApi): datamodel = SQLAInterface(Model1) - list_columns = ['field_integer', 'field_float', 'field_string', 'field_date'] + list_columns = [ + 'field_integer', + 'field_float', + 'field_string', + 'field_date' + ] + description_columns = { + 'field_integer': 'Field Integer', + 'field_float': 'Field Float', + 'field_string': 'Field String' + } + + class Model1FuncApi(ModelApi): + datamodel = SQLAInterface(Model1) + list_columns = [ + 'field_integer', + 'field_float', + 'field_string', + 'field_date', + 'full_concat' + ] description_columns = { 'field_integer': 'Field Integer', 'field_float': 'Field Float', @@ -45,7 +65,8 @@ class Model1Api(ModelApi): self.model1api = Model1Api self.appbuilder.add_view_no_menu(Model1Api) - + self.model1funcapi = Model1Api + self.appbuilder.add_view_no_menu(Model1FuncApi) def tearDown(self): self.appbuilder = None @@ -62,23 +83,23 @@ def test_get_item(self): rv = client.get('api/v1/model1api/{}/'.format(i)) data = json.loads(rv.data.decode('utf-8')) eq_(rv.status_code, 200) - self.assert_get_item(rv, data, i-1) + self.assert_get_item(rv, data, i - 1) def assert_get_item(self, rv, data, value): log.info("assert_get_item: {} {}".format(data, value)) # test result - eq_(data['result'], {'field_date':None, - 'field_float':float(value), - 'field_integer':value, - 'field_string':"test{}".format(value)}) + eq_(data['result'], {'field_date': None, + 'field_float': float(value), + 'field_integer': value, + 'field_string': "test{}".format(value)}) # test descriptions eq_(data['description_columns'], self.model1api.description_columns) # test labels - eq_(data['label_columns'], {'field_date':'Field Date', - 'field_float':'Field Float', - 'field_integer':'Field Integer', - 'field_string':'Field String', - 'id':'Id'}) + eq_(data['label_columns'], {'field_date': 'Field Date', + 'field_float': 'Field Float', + 'field_integer': 'Field Integer', + 'field_string': 'Field String', + 'id': 'Id'}) eq_(rv.status_code, 200) def test_get_item_not_found(self): @@ -103,15 +124,19 @@ def test_get_list(self): # Tests data result default page size eq_(len(data['result']), self.model1api.page_size) for i in range(1, MODEL1_DATA_SIZE): - self.assert_get_list(rv, data['result'][i-1], i-1) + self.assert_get_list(rv, data['result'][i - 1], i - 1) - def assert_get_list(self, rv, data, value): + @staticmethod + def assert_get_list(rv, data, value): log.info("assert_get_list: {} {}".format(data, value)) # test result - eq_(data, {'field_date':None, - 'field_float':float(value), - 'field_integer':value, - 'field_string':"test{}".format(value)}) + eq_(data, { + 'field_date': None, + 'field_float': float(value), + 'field_integer': value, + 'field_string': "test{}".format(value) + } + ) eq_(rv.status_code, 200) def test_get_list_order(self): @@ -123,18 +148,23 @@ def test_get_list_order(self): # test string order asc rv = client.get('api/v1/model1api/?_o_=field_string:asc') data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], {'field_date':None, - 'field_float':0.0, - 'field_integer':0, - 'field_string':"test0"}) + eq_(data['result'][0], { + 'field_date': None, + 'field_float': 0.0, + 'field_integer': 0, + 'field_string': "test0"} + ) eq_(rv.status_code, 200) # test string order desc rv = client.get('api/v1/model1api/?_o_=field_string:desc') data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], {'field_date':None, - 'field_float':float(MODEL1_DATA_SIZE-1), - 'field_integer':MODEL1_DATA_SIZE-1, - 'field_string':"test{}".format(MODEL1_DATA_SIZE-1)}) + eq_(data['result'][0], { + 'field_date': None, + 'field_float': float(MODEL1_DATA_SIZE - 1), + 'field_integer': MODEL1_DATA_SIZE - 1, + 'field_string': "test{}".format(MODEL1_DATA_SIZE - 1) + } + ) eq_(rv.status_code, 200) def test_get_list_page(self): @@ -148,20 +178,23 @@ def test_get_list_page(self): uri = 'api/v1/model1api/?_p_={}:0&_o_=field_integer:asc'.format(page_size) rv = client.get(uri) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], {'field_date':None, - 'field_float':0.0, - 'field_integer':0, - 'field_string':"test0"}) + eq_(data['result'][0], { + 'field_date': None, + 'field_float': 0.0, + 'field_integer': 0, + 'field_string': "test0" + } + ) eq_(rv.status_code, 200) eq_(len(data['result']), page_size) # test page zero uri = 'api/v1/model1api/?_p_={}:1&_o_=field_integer:asc'.format(page_size) rv = client.get(uri) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], {'field_date':None, - 'field_float':float(page_size), - 'field_integer':page_size, - 'field_string':"test{}".format(page_size)}) + eq_(data['result'][0], {'field_date': None, + 'field_float': float(page_size), + 'field_integer': page_size, + 'field_string': "test{}".format(page_size)}) eq_(rv.status_code, 200) eq_(len(data['result']), page_size) @@ -175,10 +208,10 @@ def test_get_list_filters(self): uri = 'api/v1/model1api/?_f_0=field_integer:gt:{}&_o_=field_integer:asc'.format(filter_value) rv = client.get(uri) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], {'field_date':None, - 'field_float':float(filter_value + 1), - 'field_integer':filter_value + 1, - 'field_string':"test{}".format(filter_value + 1)}) + eq_(data['result'][0], {'field_date': None, + 'field_float': float(filter_value + 1), + 'field_integer': filter_value + 1, + 'field_string': "test{}".format(filter_value + 1)}) eq_(rv.status_code, 200) def test_info_filters(self): @@ -190,35 +223,35 @@ def test_info_filters(self): rv = client.get(uri) data = json.loads(rv.data.decode('utf-8')) expected_filters = { - 'field_date': [ - {'name': 'Equal to', 'operator': 'eq'}, - {'name': 'Greater than', 'operator': 'gt'}, - {'name': 'Smaller than', 'operator': 'lt'}, - {'name': 'Not Equal to', 'operator': 'neq'} - ], - 'field_float': [ - {'name': 'Equal to', 'operator': 'eq'}, - {'name': 'Greater than', 'operator': 'gt'}, - {'name': 'Smaller than', 'operator': 'lt'}, - {'name': 'Not Equal to', 'operator': 'neq'} - ], - 'field_integer': [ - {'name': 'Equal to', 'operator': 'eq'}, - {'name': 'Greater than', 'operator': 'gt'}, - {'name': 'Smaller than', 'operator': 'lt'}, - {'name': 'Not Equal to', 'operator': 'neq'} - ], - 'field_string': [ - {'name': 'Starts with', 'operator': 'sw'}, - {'name': 'Ends with', 'operator': 'ew'}, - {'name': 'Contains', 'operator': 'ct'}, - {'name': 'Equal to', 'operator': 'eq'}, - {'name': 'Not Starts with', 'operator': 'nsw'}, - {'name': 'Not Ends with', 'operator': 'new'}, - {'name': 'Not Contains', 'operator': 'nct'}, - {'name': 'Not Equal to', 'operator': 'neq'} - ] - } + 'field_date': [ + {'name': 'Equal to', 'operator': 'eq'}, + {'name': 'Greater than', 'operator': 'gt'}, + {'name': 'Smaller than', 'operator': 'lt'}, + {'name': 'Not Equal to', 'operator': 'neq'} + ], + 'field_float': [ + {'name': 'Equal to', 'operator': 'eq'}, + {'name': 'Greater than', 'operator': 'gt'}, + {'name': 'Smaller than', 'operator': 'lt'}, + {'name': 'Not Equal to', 'operator': 'neq'} + ], + 'field_integer': [ + {'name': 'Equal to', 'operator': 'eq'}, + {'name': 'Greater than', 'operator': 'gt'}, + {'name': 'Smaller than', 'operator': 'lt'}, + {'name': 'Not Equal to', 'operator': 'neq'} + ], + 'field_string': [ + {'name': 'Starts with', 'operator': 'sw'}, + {'name': 'Ends with', 'operator': 'ew'}, + {'name': 'Contains', 'operator': 'ct'}, + {'name': 'Equal to', 'operator': 'eq'}, + {'name': 'Not Starts with', 'operator': 'nsw'}, + {'name': 'Not Ends with', 'operator': 'new'}, + {'name': 'Not Contains', 'operator': 'nct'}, + {'name': 'Not Equal to', 'operator': 'neq'} + ] + } eq_(data['filters'], expected_filters) def test_get_delete_item(self): @@ -321,7 +354,6 @@ def test_create_item(self): REST Api: Test create item """ client = self.app.test_client() - pk = 11 item = dict( field_string="test11", field_integer=11, @@ -377,3 +409,25 @@ def test_get_create_item_val_type(self): eq_(rv.status_code, 400) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Not a valid string.') + + def test_get_list_col_function(self): + """ + REST Api: Test get list of objects with columns as functions + """ + client = self.app.test_client() + rv = client.get('api/v1/model1funcapi/') + data = json.loads(rv.data.decode('utf-8')) + # Tests count property + eq_(data['count'], MODEL1_DATA_SIZE) + # Tests data result default page size + eq_(len(data['result']), self.model1api.page_size) + for i in range(1, MODEL1_DATA_SIZE): + item = data['result'][i - 1] + eq_(item['full_concat'], "{}.{}.{}.{}".format( + "test" + str(i - 1), + i - 1, + float(i - 1), + None + ) + ) + From 76ffb8986463636481101ff5e497fe102d900295 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 5 Mar 2019 18:23:32 +0000 Subject: [PATCH 014/109] [api] New, select columns on the fly for list and get item --- flask_appbuilder/api.py | 135 ++++++++++++++++++----------- flask_appbuilder/tests/test_api.py | 106 +++++++++++++++------- 2 files changed, 160 insertions(+), 81 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 0f7c45342f..8bab3ca6d2 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -12,12 +12,12 @@ URI_ORDER_BY_PREFIX = "_o_" URI_PAGE_PREFIX = "_p_" URI_FILTER_PREFIX = "_f_" +URI_SELECT_COL_PREFIX = "_c_" def order_args(f): """ Get order arguments decorator - { : (ORDER_COL, ORDER_DIRECTION) } Arguments are passed like: _o_=:'' @@ -26,7 +26,7 @@ def order_args(f): def wraps(self, *args, **kwargs): orders = {} for arg, value in request.args.items(): - if arg==URI_ORDER_BY_PREFIX: + if arg == URI_ORDER_BY_PREFIX: re_match = re.findall('(.*):(.*)', value) for _item_re_match in re_match: if _item_re_match and _item_re_match[1] in ('asc', 'desc'): @@ -47,7 +47,6 @@ def wraps(self, *args, **kwargs): def page_args(f): """ Get page arguments decorator - { : (ORDER_COL, ORDER_DIRECTION) } Arguments are passed like: _p_=:'' @@ -55,7 +54,7 @@ def page_args(f): """ def wraps(self, *args, **kwargs): for arg, value in request.args.items(): - if arg==URI_PAGE_PREFIX: + if arg == URI_PAGE_PREFIX: re_match = re.findall('(.*):(.*)', value) for _item_re_match in re_match: if _item_re_match and len(_item_re_match) == 2: @@ -74,6 +73,23 @@ def wraps(self, *args, **kwargs): return functools.update_wrapper(wraps, f) +def select_col_args(f): + """ + Get selectable columns on the fly + + Arguments are passed like: _c_=,, ... + + function is called with named args: page_size, page_index + """ + def wraps(self, *args, **kwargs): + for arg, value in request.args.items(): + if arg == URI_SELECT_COL_PREFIX: + re_match = re.findall('(\w+),*', value) + kwargs['select_cols'] = [i for i in re_match] + return f(self, *args, **kwargs) + return functools.update_wrapper(wraps, f) + + def filter_args(f): """ Get filter arguments, return a list of dicts @@ -92,7 +108,7 @@ def filter_args(f): def wraps(self, *args, **kwargs): filters = list() for arg, value in request.args.items(): - key_match = re.match("{}(\d)".format(URI_FILTER_PREFIX), arg) + key_match = re.match(r"{}(\d)".format(URI_FILTER_PREFIX), arg) if key_match: re_match = re.findall('(.*):(.*):(.*)', value) for _item_re_match in re_match: @@ -328,12 +344,14 @@ def _gen_labels_columns(self, list_columns): if not self.label_columns.get(col): self.label_columns[col] = self._prettify_column(col) - def _label_columns_json(self): + def _label_columns_json(self, cols=None): """ Prepares dict with labels to be JSON serializable """ ret = {} - for key, value in list(self.label_columns.items()): + cols = cols or [] + d = {k: v for (k, v) in self.label_columns.items() if k in cols} + for key, value in d.items(): ret[key] = as_unicode(_(value).encode('UTF-8')) return ret @@ -544,85 +562,94 @@ def post(self): try: item = self.add_model_schema.load(request.json) except ValidationError as err: - ret_code = 400 - response = {'message': err.messages} + return self._api_json_response(400, **{'message': err.messages}) else: self.pre_add(item.data) if self.datamodel.add(item.data): self.post_add(item.data) - ret_code = 201 - response = \ - {'result': self.add_model_schema.dump(item.data, - many=False).data} + return self._api_json_response( + 201, + **{'result': self.add_model_schema.dump(item.data, many=False).data} + ) else: - ret_code = 500 - response = {'message': "Internal error"} - return self._api_json_response(ret_code, **response) + return self._api_json_500() @expose('/', methods=['PUT']) @permission_name('put') def put(self, pk): item = self.datamodel.get(pk) if not item: - ret_code = 404 - response = {'message': 'Not found'} + return self._api_json_404() else: try: item = self.edit_model_schema.load(request.json, instance=item) except ValidationError as err: - ret_code = 400 - response = {'message': err.messages} + return self._api_json_response(400, **{'message': err.messages}) else: self.pre_update(item.data) if self.datamodel.edit(item.data): self.post_add(item) - ret_code = 200 - response = \ - {'result': self.edit_model_schema.dump(item.data, - many=False).data} self.post_update(item) + return self._api_json_response( + 200, + **{'result': self.edit_model_schema.dump(item.data, many=False).data} + ) else: - ret_code = 500 - response = {'message': "Internal error"} - return self._api_json_response(ret_code, **response) + return self._api_json_500() @expose('/', methods=['DELETE']) @permission_name('delete') def delete(self, pk): item = self.datamodel.get(pk, self._base_filters) if not item: - ret_code = 404 - response = {'message': 'Not found'} + return self._api_json_404() else: self.pre_delete(item) if self.datamodel.delete(item): self.post_delete(item) - ret_code = 200 - response = {'message': 'OK'} + return self._api_json_response(200, **{'message': 'OK'}) else: - ret_code = 500 - response = {'message': "Internal error"} - return self._api_json_response(ret_code, **response) + return self._api_json_500() - def _get_item(self, pk): + @select_col_args + def _get_item(self, pk, select_cols=None): item = self.datamodel.get(pk) if not item: - abort(404) + return self._api_json_404() + select_cols = select_cols or [] + _pruned_select_cols = [col for col in select_cols if col in self.show_columns] + if _pruned_select_cols: + _show_columns = _pruned_select_cols + _show_model_schema = self._model_schema_factory(_pruned_select_cols) + else: + _show_columns = self.show_columns + _show_model_schema = self.show_model_schema return self._api_json_response( 200, pk=pk, - label_columns=self._label_columns_json(), - include_columns=self.show_columns, - description_columns=self._description_columns_json(), + label_columns=self._label_columns_json(_show_columns), + include_columns=_show_columns, + description_columns=self._description_columns_json(_show_columns), modelview_name=self.__class__.__name__, - result=self.show_model_schema.dump(item, many=False).data + result=_show_model_schema.dump(item, many=False).data ) @order_args @page_args @filter_args + @select_col_args def _get_list(self, filters=None, order_column=None, order_direction=None, - page_size=0, page_index=0): - + page_size=0, page_index=0, select_cols=None): + + # handle select columns + select_cols = select_cols or [] + _pruned_select_cols = [col for col in select_cols if col in self.list_columns] + if _pruned_select_cols: + _list_columns = _pruned_select_cols + _list_model_schema = self._model_schema_factory(_pruned_select_cols) + else: + _list_columns = self.list_columns + _list_model_schema = self.list_model_schema + # handle filters self._filters.clear_filters() self._filters.rest_add_filters(filters) # Make the query @@ -634,14 +661,14 @@ def _get_list(self, filters=None, order_column=None, order_direction=None, pks = self.datamodel.get_keys(lst) return self._api_json_response( 200, - label_columns=self._label_columns_json(), - list_columns=self.list_columns, - description_columns=self._description_columns_json(), - order_columns=self.order_columns, + label_columns=self._label_columns_json(_list_columns), + list_columns=_list_columns, + description_columns=self._description_columns_json(_list_columns), + order_columns=[order_col for order_col in self.order_columns if order_col in _list_columns], modelview_name=self.__class__.__name__, count=count, ids=pks, - result=self.list_model_schema.dump(lst, many=True).data + result=_list_model_schema.dump(lst, many=True).data ) """ ------------------------------------------------ @@ -655,12 +682,22 @@ def _api_json_response(code, **kwargs): response.headers['Content-Type'] = "application/json; charset=utf-8" return response - def _description_columns_json(self): + def _api_json_404(self): + return self._api_json_response(404, **{"message": "Not found"}) + + def _api_json_500(self, message=None): + message = message or "Internal error" + return self._api_json_response(500, **{"message": message}) + + + def _description_columns_json(self, cols=None): """ Prepares dict with col descriptions to be JSON serializable """ ret = {} - for key, value in list(self.description_columns.items()): + cols = cols or [] + d = {k: v for (k, v) in self.description_columns.items() if k in cols} + for key, value in d.items(): ret[key] = as_unicode(_(value).encode('UTF-8')) return ret diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 7d130a1326..d8b5af1770 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -86,22 +86,41 @@ def test_get_item(self): self.assert_get_item(rv, data, i - 1) def assert_get_item(self, rv, data, value): - log.info("assert_get_item: {} {}".format(data, value)) - # test result - eq_(data['result'], {'field_date': None, - 'field_float': float(value), - 'field_integer': value, - 'field_string': "test{}".format(value)}) + eq_(data['result'], { + 'field_date': None, + 'field_float': float(value), + 'field_integer': value, + 'field_string': "test{}".format(value) + }) # test descriptions eq_(data['description_columns'], self.model1api.description_columns) # test labels - eq_(data['label_columns'], {'field_date': 'Field Date', - 'field_float': 'Field Float', - 'field_integer': 'Field Integer', - 'field_string': 'Field String', - 'id': 'Id'}) + eq_(data['label_columns'], { + 'field_date': 'Field Date', + 'field_float': 'Field Float', + 'field_integer': 'Field Integer', + 'field_string': 'Field String' + }) eq_(rv.status_code, 200) + def test_get_item_select_cols(self): + """ + REST Api: Test get item with select columns + """ + client = self.app.test_client() + + for i in range(1, MODEL1_DATA_SIZE): + rv = client.get('api/v1/model1api/{}/?_c_=field_integer'.format(i)) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['result'], {'field_integer': i - 1}) + eq_(data['description_columns'], { + 'field_integer': 'Field Integer' + }) + eq_(data['label_columns'], { + 'field_integer': 'Field Integer' + }) + eq_(rv.status_code, 200) + def test_get_item_not_found(self): """ REST Api: Test get item not found @@ -135,8 +154,7 @@ def assert_get_list(rv, data, value): 'field_float': float(value), 'field_integer': value, 'field_string': "test{}".format(value) - } - ) + }) eq_(rv.status_code, 200) def test_get_list_order(self): @@ -152,8 +170,8 @@ def test_get_list_order(self): 'field_date': None, 'field_float': 0.0, 'field_integer': 0, - 'field_string': "test0"} - ) + 'field_string': "test0" + }) eq_(rv.status_code, 200) # test string order desc rv = client.get('api/v1/model1api/?_o_=field_string:desc') @@ -163,8 +181,7 @@ def test_get_list_order(self): 'field_float': float(MODEL1_DATA_SIZE - 1), 'field_integer': MODEL1_DATA_SIZE - 1, 'field_string': "test{}".format(MODEL1_DATA_SIZE - 1) - } - ) + }) eq_(rv.status_code, 200) def test_get_list_page(self): @@ -183,18 +200,19 @@ def test_get_list_page(self): 'field_float': 0.0, 'field_integer': 0, 'field_string': "test0" - } - ) + }) eq_(rv.status_code, 200) eq_(len(data['result']), page_size) # test page zero uri = 'api/v1/model1api/?_p_={}:1&_o_=field_integer:asc'.format(page_size) rv = client.get(uri) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], {'field_date': None, - 'field_float': float(page_size), - 'field_integer': page_size, - 'field_string': "test{}".format(page_size)}) + eq_(data['result'][0], { + 'field_date': None, + 'field_float': float(page_size), + 'field_integer': page_size, + 'field_string': "test{}".format(page_size) + }) eq_(rv.status_code, 200) eq_(len(data['result']), page_size) @@ -208,10 +226,34 @@ def test_get_list_filters(self): uri = 'api/v1/model1api/?_f_0=field_integer:gt:{}&_o_=field_integer:asc'.format(filter_value) rv = client.get(uri) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], {'field_date': None, - 'field_float': float(filter_value + 1), - 'field_integer': filter_value + 1, - 'field_string': "test{}".format(filter_value + 1)}) + eq_(data['result'][0], { + 'field_date': None, + 'field_float': float(filter_value + 1), + 'field_integer': filter_value + 1, + 'field_string': "test{}".format(filter_value + 1) + }) + eq_(rv.status_code, 200) + + def test_get_list_select_cols(self): + """ + REST Api: Test get list with selected columns + """ + client = self.app.test_client() + uri = 'api/v1/model1api/?_c_=field_integer&_o_=field_integer:asc' + rv = client.get(uri) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['result'][0], { + 'field_integer': 0, + }) + eq_(data['label_columns'], { + 'field_integer': 'Field Integer' + }) + eq_(data['description_columns'], { + 'field_integer': 'Field Integer' + }) + eq_(data['list_columns'], [ + 'field_integer' + ]) eq_(rv.status_code, 200) def test_info_filters(self): @@ -254,7 +296,7 @@ def test_info_filters(self): } eq_(data['filters'], expected_filters) - def test_get_delete_item(self): + def test_delete_item(self): """ REST Api: Test delete item """ @@ -265,7 +307,7 @@ def test_get_delete_item(self): model = self.db.session.query(Model1).get(pk) eq_(model, None) - def test_get_delete_item_not_found(self): + def test_delete_item_not_found(self): """ REST Api: Test delete item not found """ @@ -274,7 +316,7 @@ def test_get_delete_item_not_found(self): rv = client.delete('api/v1/model1api/{}'.format(pk)) eq_(rv.status_code, 404) - def test_get_update_item(self): + def test_update_item(self): """ REST Api: Test update item """ @@ -292,7 +334,7 @@ def test_get_update_item(self): eq_(model.field_integer, 0) eq_(model.field_float, 0.0) - def test_get_update_item_not_found(self): + def test_update_item_not_found(self): """ REST Api: Test update item not found """ @@ -306,7 +348,7 @@ def test_get_update_item_not_found(self): rv = client.put('api/v1/model1api/{}'.format(pk), json=item) eq_(rv.status_code, 404) - def test_get_update_val_size(self): + def test_update_val_size(self): """ REST Api: Test update validate size """ From 1abb45ed15f61091da582ec7050c90cad0d79a24 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 5 Mar 2019 18:24:57 +0000 Subject: [PATCH 015/109] [style] Fix, quickhowto compliant with PEP8 --- examples/quickhowto/app/models.py | 4 +++- examples/quickhowto/app/views.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/quickhowto/app/models.py b/examples/quickhowto/app/models.py index a162dd811d..fae3384da2 100644 --- a/examples/quickhowto/app/models.py +++ b/examples/quickhowto/app/models.py @@ -14,6 +14,7 @@ class ContactGroup(Model): def __repr__(self): return self.name + class ContactGroupSchema(Schema): name = fields.Str(required=True) @@ -28,7 +29,7 @@ def __repr__(self): class Contact(Model): id = Column(Integer, primary_key=True) - name = Column(String(150), unique = True, nullable=False) + name = Column(String(150), unique = True, nullable=False) address = Column(String(564)) birthday = Column(Date, nullable=True) personal_phone = Column(String(20)) @@ -49,6 +50,7 @@ def year(self): date = self.birthday or mindate return datetime.datetime(date.year, 1, 1) + class ContactSchema(Schema): name = fields.Str(required=True) address = fields.Str() diff --git a/examples/quickhowto/app/views.py b/examples/quickhowto/app/views.py index dbcce7da99..0d3c66db70 100644 --- a/examples/quickhowto/app/views.py +++ b/examples/quickhowto/app/views.py @@ -20,7 +20,6 @@ def fill_gender(): db.session.rollback() - class ContactModelView(ModelView): datamodel = SQLAInterface(Contact) @@ -53,18 +52,22 @@ class GroupModelView(ModelView): datamodel = SQLAInterface(ContactGroup) related_views = [ContactModelView] + class GroupModelApi(ModelApi): datamodel = SQLAInterface(ContactGroup) + class ContactModelApi(ModelApi): datamodel = SQLAInterface(Contact) #show_columns = ['name'] #list_model_schema = ContactSchema() list_columns = ['name', 'address', 'personal_celphone'] + class UserModelApi(ModelApi): datamodel = SQLAInterface(User) + class ContactChartView(GroupByChartView): datamodel = SQLAInterface(Contact) chart_title = 'Grouped contacts' @@ -86,6 +89,7 @@ class ContactChartView(GroupByChartView): def pretty_month_year(value): return calendar.month_name[value.month] + ' ' + str(value.year) + def pretty_year(value): return str(value.year) @@ -122,6 +126,7 @@ class ContactTimeChartView(GroupByChartView): appbuilder.add_view(ContactChartView, "Contacts Chart", icon="fa-dashboard", category="Contacts") appbuilder.add_view(ContactTimeChartView, "Contacts Birth Chart", icon="fa-dashboard", category="Contacts") + @app.after_request def after_request(response): response.headers.add('Access-Control-Allow-Origin', '*') From 4244237c0208d56016581a3a678233eb51e00851 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 14 Mar 2019 20:32:01 +0000 Subject: [PATCH 016/109] [api] New, endpoint info for add_fields, edit_fields. Base filter and tests, add_query_rel_fields, edit_query_rel_fields --- flask_appbuilder/api.py | 124 ++++++++++++++++++++++-- flask_appbuilder/tests/test_api.py | 149 +++++++++++++++++++++++++++-- 2 files changed, 260 insertions(+), 13 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 8bab3ca6d2..c712c352e4 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -62,7 +62,7 @@ def wraps(self, *args, **kwargs): kwargs['page_size'] = int(_item_re_match[0]) kwargs['page_index'] = int(_item_re_match[1]) return f(self, *args, **kwargs) - except ValueError as e: + except ValueError: log.warning("Bad page args {}, {}".format( _item_re_match[0], _item_re_match[1]) @@ -453,6 +453,35 @@ class MyView(ModelView): formatters_columns = {'some_date_col': lambda x: x.isoformat() } """ + add_query_rel_fields = None + """ + Add Customized query for related add fields. + Assign a dictionary where the keys are the column names of + the related models to filter, the value for each key, is a list of lists with the + same format as base_filter + {'relation col name':[['Related model col',FilterClass,'Filter Value'],...],...} + Add a custom filter to form related fields:: + + class ContactModelView(ModelView): + datamodel = SQLAModel(Contact, db.session) + add_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} + + """ + edit_query_rel_fields = None + """ + Add Customized query for related edit fields. + Assign a dictionary where the keys are the column names of + the related models to filter, the value for each key, is a list of lists with the + same format as base_filter + {'relation col name':[['Related model col',FilterClass,'Filter Value'],...],...} + Add a custom filter to form related fields:: + + class ContactModelView(ModelView): + datamodel = SQLAModel(Contact, db.session) + edit_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} + + """ + def create_blueprint(self, appbuilder, *args, **kwargs): self._init_model_schemas() return super(ModelApi, self).create_blueprint(appbuilder, @@ -535,10 +564,82 @@ def _init_properties(self): self.edit_columns = \ [x for x in list_cols if x not in self.edit_exclude_columns] self._filters = self.datamodel.get_filters(self.search_columns) + self.edit_query_rel_fields = self.edit_query_rel_fields or dict() + self.add_query_rel_fields = self.add_query_rel_fields or dict() + + def _get_field_info(self, field, filter_rel_field): + """ + Return a dict with field details + ready to serve as a response + + :param field: marshmallow field + :return: dict with field details + """ + from marshmallow_sqlalchemy.fields import Related, RelatedList + ret = dict() + ret['name'] = field.name + ret['label'] = self.label_columns.get(field.name, '') + ret['description'] = self.description_columns.get(field.name, '') + # Handles related fields + if isinstance(field, Related) or isinstance(field, RelatedList): + _rel_interface = self.datamodel.get_related_interface(field.name) + _filters = _rel_interface .get_filters(_rel_interface .get_search_columns_list()) + if filter_rel_field: + filters = _filters.add_filter_list(filter_rel_field) + _values = _rel_interface.query(filters)[1] + else: + _values = _rel_interface.query()[1] + ret['values'] = list() + for _value in _values: + ret['values'].append( + { + "id": _rel_interface.get_pk_value(_value), + "value": str(_value) + } + ) + + if field.validate: + ret['validate'] = [str(v) for v in field.validate] + ret['type'] = field.__class__.__name__ + ret['required'] = field.required + return ret + + def _get_fields_info(self, cols, model_schema, filter_rel_fields): + """ + Returns a dict with fields detail + from a marshmallow schema + + :param cols: list of columns to show info for + :param model_schema: Marshmallow model schema + :param filter_rel_fields: expects add_query_rel_fields or + edit_query_rel_fields + :return: dict with all fields details + """ + return [ + self._get_field_info( + model_schema.fields[col], + filter_rel_fields.get(col, []) + ) + for col in cols + ] @expose('/info', methods=['GET']) @permission_name('get') def info(self): + # Get info from add fields + _add_fields = self._get_fields_info( + self.add_columns, + self.add_model_schema, + self.add_query_rel_fields + ) + # Get info from edit fields + _edit_fields = self._get_fields_info( + self.edit_columns, + self.edit_model_schema, + self.edit_query_rel_fields + ) + + # Get possible search fields and all possible operations search_filters = dict() dict_filters = self._filters.get_search_filters() for col in self.search_columns: @@ -546,7 +647,12 @@ def info(self): {'name': as_unicode(flt.name), 'operator': flt.arg_name} for flt in dict_filters[col] ] - return self._api_json_response(200, filters=search_filters) + return self._api_json_response( + 200, + filters=search_filters, + add_fields=_add_fields, + edit_fields=_edit_fields + ) @expose('/', methods=['GET']) @expose('//', methods=['GET']) @@ -577,7 +683,7 @@ def post(self): @expose('/', methods=['PUT']) @permission_name('put') def put(self, pk): - item = self.datamodel.get(pk) + item = self.datamodel.get(pk, self._base_filters) if not item: return self._api_json_404() else: @@ -613,7 +719,7 @@ def delete(self, pk): @select_col_args def _get_item(self, pk, select_cols=None): - item = self.datamodel.get(pk) + item = self.datamodel.get(pk, self._base_filters) if not item: return self._api_json_404() select_cols = select_cols or [] @@ -652,19 +758,24 @@ def _get_list(self, filters=None, order_column=None, order_direction=None, # handle filters self._filters.clear_filters() self._filters.rest_add_filters(filters) + joined_filters = self._filters.get_joined_filters(self._base_filters) # Make the query - count, lst = self.datamodel.query(self._filters, + count, lst = self.datamodel.query(joined_filters, order_column, order_direction, page=page_index, page_size=page_size) pks = self.datamodel.get_keys(lst) + order_columns = [ + order_col + for order_col in self.order_columns if order_col in _list_columns + ] return self._api_json_response( 200, label_columns=self._label_columns_json(_list_columns), list_columns=_list_columns, description_columns=self._description_columns_json(_list_columns), - order_columns=[order_col for order_col in self.order_columns if order_col in _list_columns], + order_columns=order_columns, modelview_name=self.__class__.__name__, count=count, ids=pks, @@ -689,7 +800,6 @@ def _api_json_500(self, message=None): message = message or "Internal error" return self._api_json_response(500, **{"message": message}) - def _description_columns_json(self, cols=None): """ Prepares dict with col descriptions to be JSON serializable diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index d8b5af1770..9da1085311 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -7,10 +7,13 @@ import logging from nose.tools import eq_, ok_ - -log = logging.getLogger(__name__) from flask_appbuilder import SQLA from .sqla.models import Model1, insert_data +from flask_appbuilder.models.sqla.filters import \ + FilterGreater, FilterSmaller + + +log = logging.getLogger(__name__) MODEL1_DATA_SIZE = 10 @@ -48,6 +51,19 @@ class Model1Api(ModelApi): 'field_string': 'Field String' } + class Model1ApiFieldsInfo(Model1Api): + datamodel = SQLAInterface(Model1) + add_columns = [ + 'field_integer', + 'field_float', + 'field_string', + 'field_date' + ] + edit_columns = [ + 'field_string', + 'field_integer' + ] + class Model1FuncApi(ModelApi): datamodel = SQLAInterface(Model1) list_columns = [ @@ -63,10 +79,20 @@ class Model1FuncApi(ModelApi): 'field_string': 'Field String' } + class Model1ApiFiltered(ModelApi): + datamodel = SQLAInterface(Model1) + base_filters = [ + ['field_integer', FilterGreater, 2], + ['field_integer', FilterSmaller, 4], + ] + self.model1api = Model1Api self.appbuilder.add_view_no_menu(Model1Api) self.model1funcapi = Model1Api self.appbuilder.add_view_no_menu(Model1FuncApi) + self.model1apifieldsinfo = Model1ApiFieldsInfo + self.appbuilder.add_view_no_menu(Model1ApiFieldsInfo) + self.appbuilder.add_view_no_menu(Model1ApiFiltered) def tearDown(self): self.appbuilder = None @@ -130,6 +156,21 @@ def test_get_item_not_found(self): rv = client.get('api/v1/model1api/{}/'.format(pk)) eq_(rv.status_code, 404) + def test_get_item_base_filters(self): + """ + REST Api: Test get item with base filters + """ + client = self.app.test_client() + # We can't get a base filtered item + pk = 1 + rv = client.get('api/v1/model1apifiltered/{}/'.format(pk)) + eq_(rv.status_code, 404) + client = self.app.test_client() + # This one is ok pk=4 field_integer=3 2>3<4 + pk = 4 + rv = client.get('api/v1/model1apifiltered/{}/'.format(pk)) + eq_(rv.status_code, 200) + def test_get_list(self): """ REST Api: Test get list @@ -147,8 +188,6 @@ def test_get_list(self): @staticmethod def assert_get_list(rv, data, value): - log.info("assert_get_list: {} {}".format(data, value)) - # test result eq_(data, { 'field_date': None, 'field_float': float(value), @@ -256,6 +295,25 @@ def test_get_list_select_cols(self): ]) eq_(rv.status_code, 200) + def test_get_list_base_filters(self): + """ + REST Api: Test get list with base filters + """ + client = self.app.test_client() + uri = 'api/v1/model1apifiltered/?_o_=field_integer:asc' + rv = client.get(uri) + data = json.loads(rv.data.decode('utf-8')) + expected_result = [ + { + 'field_date': None, + 'field_float': 3.0, + 'field_integer': 3, + 'field_string': 'test3', + 'id': 4 + } + ] + eq_(data['result'], expected_result) + def test_info_filters(self): """ REST Api: Test info filters @@ -296,6 +354,52 @@ def test_info_filters(self): } eq_(data['filters'], expected_filters) + def test_info_fields(self): + """ + REST Api: Test info fields (add, edit) + """ + client = self.app.test_client() + uri = 'api/v1/model1apifieldsinfo/info' + rv = client.get(uri) + data = json.loads(rv.data.decode('utf-8')) + expect_add_fields = [ + { + 'description': 'Field Integer', + 'label': 'Field Integer', + 'name': 'field_integer', + 'required': False, 'type': 'Integer' + }, + { + 'description': 'Field Float', + 'label': 'Field Float', + 'name': 'field_float', + 'required': False, + 'type': 'Float' + }, + { + 'description': 'Field String', + 'label': 'Field String', + 'name': 'field_string', + 'required': True, + 'type': 'String', + 'validate': [''] + }, + { + 'description': '', + 'label': 'Field Date', + 'name': 'field_date', + 'required': False, + 'type': 'Date' + } + ] + expect_edit_fields = list() + for edit_col in self.model1apifieldsinfo.edit_columns: + for item in expect_add_fields: + if item['name'] == edit_col: + expect_edit_fields.append(item) + eq_(data['add_fields'], expect_add_fields) + eq_(data['edit_fields'], expect_edit_fields) + def test_delete_item(self): """ REST Api: Test delete item @@ -316,6 +420,16 @@ def test_delete_item_not_found(self): rv = client.delete('api/v1/model1api/{}'.format(pk)) eq_(rv.status_code, 404) + def test_delete_item_base_filters(self): + """ + REST Api: Test delete item with base filters + """ + client = self.app.test_client() + # Try to delete a filtered item + pk = 1 + rv = client.delete('api/v1/model1apifiltered/{}'.format(pk)) + eq_(rv.status_code, 404) + def test_update_item(self): """ REST Api: Test update item @@ -334,6 +448,29 @@ def test_update_item(self): eq_(model.field_integer, 0) eq_(model.field_float, 0.0) + def test_update_item_base_filters(self): + """ + REST Api: Test update item with base filters + """ + client = self.app.test_client() + pk = 4 + item = dict( + field_string="test_Put", + field_integer=3, + field_float=3.0 + ) + rv = client.put('api/v1/model1apifiltered/{}'.format(pk), json=item) + eq_(rv.status_code, 200) + model = self.db.session.query(Model1).get(pk) + eq_(model.field_string, "test_Put") + eq_(model.field_integer, 3) + eq_(model.field_float, 3.0) + # We can't update an item that is base filtered + client = self.app.test_client() + pk = 1 + rv = client.put('api/v1/model1apifiltered/{}'.format(pk), json=item) + eq_(rv.status_code, 404) + def test_update_item_not_found(self): """ REST Api: Test update item not found @@ -365,7 +502,7 @@ def test_update_val_size(self): data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Longer than maximum length 50.') - def test_get_update_item_val_type(self): + def test_update_item_val_type(self): """ REST Api: Test update validate type """ @@ -427,7 +564,7 @@ def test_create_item_val_size(self): data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Longer than maximum length 50.') - def test_get_create_item_val_type(self): + def test_create_item_val_type(self): """ REST Api: Test create validate type """ From bd2f4403cc78211f82fb42f6f5140c2608ed057c Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 15 Mar 2019 09:06:56 +0000 Subject: [PATCH 017/109] [style] Fix, PEP8 on tests --- flask_appbuilder/tests/test_base.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/flask_appbuilder/tests/test_base.py b/flask_appbuilder/tests/test_base.py index a778c93a75..cf8148200a 100644 --- a/flask_appbuilder/tests/test_base.py +++ b/flask_appbuilder/tests/test_base.py @@ -40,7 +40,6 @@ log = logging.getLogger(__name__) - class FlaskTestCase(unittest.TestCase): def setUp(self): from flask import Flask @@ -67,7 +66,6 @@ class PSView(ModelView): list_columns = ['UID', 'C', 'CMD', 'TIME'] search_columns = ['UID', 'C', 'CMD'] - class Model2View(ModelView): datamodel = SQLAInterface(Model2) list_columns = ['field_integer', 'field_float', 'field_string', 'field_method', 'group.field_string'] @@ -115,23 +113,19 @@ class Model1Filtered1View(ModelView): datamodel = SQLAInterface(Model1) base_filters = [['field_string', FilterStartsWith, 'a']] - class Model1MasterView(MasterDetailView): datamodel = SQLAInterface(Model1) related_views = [Model2View] - class Model1Filtered2View(ModelView): datamodel = SQLAInterface(Model1) base_filters = [['field_integer', FilterEqual, 0]] - class Model2ChartView(ChartView): datamodel = SQLAInterface(Model2) chart_title = 'Test Model1 Chart' group_by_columns = ['field_string'] - class Model2GroupByChartView(GroupByChartView): datamodel = SQLAInterface(Model2) chart_title = 'Test Model1 Chart' From d680fa053825fb5bb85415cd2b1bf448a7324cd3 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 15 Mar 2019 10:24:29 +0000 Subject: [PATCH 018/109] [tests] New, info endpoint _query_rel_fields and related fields --- examples/quickhowto/app/views.py | 9 +++ flask_appbuilder/tests/sqla/models.py | 19 ++++- flask_appbuilder/tests/test_api.py | 105 +++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 7 deletions(-) diff --git a/examples/quickhowto/app/views.py b/examples/quickhowto/app/views.py index 0d3c66db70..afb9dda5cc 100644 --- a/examples/quickhowto/app/views.py +++ b/examples/quickhowto/app/views.py @@ -6,6 +6,7 @@ from flask_babel import lazy_gettext as _ from flask_appbuilder.api import ModelApi from flask_appbuilder.security.sqla.models import User +from flask_appbuilder.models.sqla.filters import FilterStartsWith, FilterEqualFunction from app import db, appbuilder, app from .models import ContactGroup, Gender, Contact, ContactGroupSchema, ContactSchema @@ -61,6 +62,14 @@ class ContactModelApi(ModelApi): datamodel = SQLAInterface(Contact) #show_columns = ['name'] #list_model_schema = ContactSchema() + base_filters = [['name', FilterStartsWith, 'a']] + add_query_rel_fields = { + 'contact_group': [['name', FilterStartsWith, 'F']] + } + edit_query_rel_fields = { + 'contact_group': [['name', FilterStartsWith, 'F']] + } + list_columns = ['name', 'address', 'personal_celphone'] diff --git a/flask_appbuilder/tests/sqla/models.py b/flask_appbuilder/tests/sqla/models.py index 2ab2d5a49d..9fafab3841 100644 --- a/flask_appbuilder/tests/sqla/models.py +++ b/flask_appbuilder/tests/sqla/models.py @@ -68,10 +68,21 @@ class ModelWithEnums(Model): """ -def insert_data(session, model1, count): +def insert_data(session, count): + model1_collection = list() for i in range(count): - model = model1(field_string="test{}".format(i), - field_integer=i, - field_float=float(i)) + model = Model1() + model.field_string = "test{}".format(i) + model.field_integer = i + model.field_float = float(i) + session.add(model) + session.commit() + model1_collection.append(model) + for i in range(count): + model = Model2() + model.field_string = "test{}".format(i) + model.field_integer = i + model.field_float = float(i) + model.group = model1_collection[i] session.add(model) session.commit() diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 9da1085311..a94d8d99d1 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -8,7 +8,7 @@ from nose.tools import eq_, ok_ from flask_appbuilder import SQLA -from .sqla.models import Model1, insert_data +from .sqla.models import Model1, Model2, insert_data from flask_appbuilder.models.sqla.filters import \ FilterGreater, FilterSmaller @@ -16,6 +16,7 @@ log = logging.getLogger(__name__) MODEL1_DATA_SIZE = 10 +MODEL2_DATA_SIZE = 10 class FlaskTestCase(unittest.TestCase): @@ -35,7 +36,7 @@ def setUp(self): self.db = SQLA(self.app) self.appbuilder = AppBuilder(self.app, self.db.session, update_perms=False) # Create models and insert data - insert_data(self.db.session, Model1, MODEL1_DATA_SIZE) + insert_data(self.db.session, MODEL1_DATA_SIZE) class Model1Api(ModelApi): datamodel = SQLAInterface(Model1) @@ -83,7 +84,7 @@ class Model1ApiFiltered(ModelApi): datamodel = SQLAInterface(Model1) base_filters = [ ['field_integer', FilterGreater, 2], - ['field_integer', FilterSmaller, 4], + ['field_integer', FilterSmaller, 4] ] self.model1api = Model1Api @@ -94,6 +95,36 @@ class Model1ApiFiltered(ModelApi): self.appbuilder.add_view_no_menu(Model1ApiFieldsInfo) self.appbuilder.add_view_no_menu(Model1ApiFiltered) + class Model2Api(ModelApi): + datamodel = SQLAInterface(Model2) + list_columns = [ + 'group' + ] + show_columns = [ + 'group' + ] + + class Model2ApiFilteredRelFields(ModelApi): + datamodel = SQLAInterface(Model2) + list_columns = [ + 'group' + ] + show_columns = [ + 'group' + ] + add_query_rel_fields = { + 'group': [ + ['field_integer', FilterGreater, 2], + ['field_integer', FilterSmaller, 4] + ] + } + edit_query_rel_fields = add_query_rel_fields + + self.model2api = Model2Api + self.appbuilder.add_view_no_menu(Model2Api) + self.model2apifilteredrelfields = Model2ApiFilteredRelFields + self.appbuilder.add_view_no_menu(Model2ApiFilteredRelFields) + def tearDown(self): self.appbuilder = None self.app = None @@ -171,6 +202,18 @@ def test_get_item_base_filters(self): rv = client.get('api/v1/model1apifiltered/{}/'.format(pk)) eq_(rv.status_code, 200) + def test_get_item_rel_field(self): + """ + REST Api: Test get item with with related fields + """ + client = self.app.test_client() + # We can't get a base filtered item + pk = 1 + rv = client.get('api/v1/model2api/{}/'.format(pk)) + data = json.loads(rv.data.decode('utf-8')) + eq_(rv.status_code, 200) + eq_(data['result'], {'group': 1}) + def test_get_list(self): """ REST Api: Test get list @@ -400,6 +443,62 @@ def test_info_fields(self): eq_(data['add_fields'], expect_add_fields) eq_(data['edit_fields'], expect_edit_fields) + def test_info_fields_rel_field(self): + """ + REST Api: Test info fields with related fields + """ + client = self.app.test_client() + uri = 'api/v1/model2api/info' + rv = client.get(uri) + data = json.loads(rv.data.decode('utf-8')) + expected_rel_add_field = { + 'description': '', + 'label': 'Group', + 'name': 'group', + 'required': False, + 'type': 'Related', + 'values': [] + } + for i in range(MODEL1_DATA_SIZE): + expected_rel_add_field['values'].append( + { + 'id': i + 1, + 'value': "test{}".format(i) + } + ) + for rel_field in data['add_fields']: + if rel_field['name'] == 'group': + eq_(rel_field, expected_rel_add_field) + + def test_info_fields_rel_filtered_field(self): + """ + REST Api: Test info fields with filtered + related fields + """ + client = self.app.test_client() + uri = 'api/v1/model2apifilteredrelfields/info' + rv = client.get(uri) + data = json.loads(rv.data.decode('utf-8')) + expected_rel_add_field = { + 'description': '', + 'label': 'Group', + 'name': 'group', + 'required': False, + 'type': 'Related', + 'values': [ + { + 'id': 4, + 'value': 'test3' + } + ] + } + for rel_field in data['add_fields']: + if rel_field['name'] == 'group': + eq_(rel_field, expected_rel_add_field) + for rel_field in data['edit_fields']: + if rel_field['name'] == 'group': + eq_(rel_field, expected_rel_add_field) + def test_delete_item(self): """ REST Api: Test delete item From 3fc03c89616292fd349ba5736c69975728619e2a Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 15 Mar 2019 10:47:30 +0000 Subject: [PATCH 019/109] [api] New, base_order property --- flask_appbuilder/api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index c712c352e4..dfd4907d1a 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -759,6 +759,9 @@ def _get_list(self, filters=None, order_column=None, order_direction=None, self._filters.clear_filters() self._filters.rest_add_filters(filters) joined_filters = self._filters.get_joined_filters(self._base_filters) + # handle base order + if not order_column and self.base_order: + order_column, order_direction = self.base_order # Make the query count, lst = self.datamodel.query(joined_filters, order_column, From bf04732d8a614c368b57cf53cbd291605117932c Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 15 Mar 2019 10:47:56 +0000 Subject: [PATCH 020/109] [tests] New, base_order property --- flask_appbuilder/tests/test_api.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index a94d8d99d1..94776ac30e 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -80,6 +80,10 @@ class Model1FuncApi(ModelApi): 'field_string': 'Field String' } + class Model1ApiOrder(ModelApi): + datamodel = SQLAInterface(Model1) + base_order = ('field_integer', 'desc') + class Model1ApiFiltered(ModelApi): datamodel = SQLAInterface(Model1) base_filters = [ @@ -93,6 +97,7 @@ class Model1ApiFiltered(ModelApi): self.appbuilder.add_view_no_menu(Model1FuncApi) self.model1apifieldsinfo = Model1ApiFieldsInfo self.appbuilder.add_view_no_menu(Model1ApiFieldsInfo) + self.appbuilder.add_view_no_menu(Model1ApiOrder) self.appbuilder.add_view_no_menu(Model1ApiFiltered) class Model2Api(ModelApi): @@ -266,6 +271,23 @@ def test_get_list_order(self): }) eq_(rv.status_code, 200) + def test_get_list_base_order(self): + """ + REST Api: Test get list with base order + """ + client = self.app.test_client() + + # test string order asc + rv = client.get('api/v1/model1apiorder/') + data = json.loads(rv.data.decode('utf-8')) + eq_(data['result'][0], { + 'id': MODEL1_DATA_SIZE, + 'field_date': None, + 'field_float': float(MODEL1_DATA_SIZE - 1), + 'field_integer': MODEL1_DATA_SIZE - 1, + 'field_string': "test{}".format(MODEL1_DATA_SIZE - 1) + }) + def test_get_list_page(self): """ REST Api: Test get list page params From c7cc4192e4d0d90b4fead81c546c47f9b59a8d50 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 15 Mar 2019 10:57:12 +0000 Subject: [PATCH 021/109] [tests] New, base_order property override --- flask_appbuilder/tests/test_api.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 94776ac30e..be9145c204 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -287,6 +287,16 @@ def test_get_list_base_order(self): 'field_integer': MODEL1_DATA_SIZE - 1, 'field_string': "test{}".format(MODEL1_DATA_SIZE - 1) }) + # Test override + rv = client.get('api/v1/model1apiorder/?_o_=field_integer:asc') + data = json.loads(rv.data.decode('utf-8')) + eq_(data['result'][0], { + 'id': 1, + 'field_date': None, + 'field_float': 0.0, + 'field_integer': 0, + 'field_string': "test0" + }) def test_get_list_page(self): """ From caf91099b0996d0df573a18908b0329836f5db89 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 15 Mar 2019 14:54:40 +0000 Subject: [PATCH 022/109] [api] New, _exclude_columns property --- flask_appbuilder/api.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index dfd4907d1a..735f2b5c73 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -404,32 +404,35 @@ class ModelApi(BaseModelApi): """ show_columns = None """ - A list of columns (or model's methods) to be displayed on the show view. - Use it to control the order of the display + A list of columns (or model's methods) for the get item endpoint. + Use it to control the order of the results """ add_columns = None """ - A list of columns (or model's methods) to be displayed - on the add form view. Use it to control the order of the display + A list of columns (or model's methods) to be allowed to post """ edit_columns = None """ - A list of columns (or model's methods) to be displayed - on the edit form view. Use it to control the order of the display + A list of columns (or model's methods) to be allowed to update + """ + list_exclude_columns = None + """ + A list of columns to exclude from the get list endpoint. + By default all columns are included. """ show_exclude_columns = None """ - A list of columns to exclude from the show view. + A list of columns to exclude from the get item endpoint. By default all columns are included. """ add_exclude_columns = None """ - A list of columns to exclude from the add form. + A list of columns to exclude from the add endpoint. By default all columns are included. """ edit_exclude_columns = None """ - A list of columns to exclude from the edit form. + A list of columns to exclude from the edit endpoint. By default all columns are included. """ order_columns = None @@ -539,17 +542,20 @@ def _init_properties(self): # Reset init props self.description_columns = self.description_columns or {} self.formatters_columns = self.formatters_columns or {} + self.list_exclude_columns = self.list_exclude_columns or [] self.show_exclude_columns = self.show_exclude_columns or [] self.add_exclude_columns = self.add_exclude_columns or [] self.edit_exclude_columns = self.edit_exclude_columns or [] # Generate base props list_cols = self.datamodel.get_user_columns_list() if not self.list_columns and self.list_model_schema: - self.list_columns =\ - list(self.list_model_schema._declared_fields.keys()) + list(self.list_model_schema._declared_fields.keys()) else: - self.list_columns = self.list_columns or \ - self.datamodel.get_columns_list() + self.list_columns = self.list_columns or [ + x for x in self.datamodel.get_columns_list() + if x not in self.list_exclude_columns + ] + self._gen_labels_columns(self.list_columns) self.order_columns = self.order_columns or \ self.datamodel.get_order_columns_list(list_columns=self.list_columns) @@ -796,6 +802,10 @@ def _api_json_response(code, **kwargs): response.headers['Content-Type'] = "application/json; charset=utf-8" return response + def _api_json_400(self, message=None): + message = message or "Arguments are not correct" + return self._api_json_response(400, **{"message": message}) + def _api_json_404(self): return self._api_json_response(404, **{"message": "Not found"}) From 71d7da4984c8365bd9eb8deb105b592907d5239e Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 15 Mar 2019 14:55:19 +0000 Subject: [PATCH 023/109] [tests] New, _exclude_columns property --- flask_appbuilder/tests/test_api.py | 92 +++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index be9145c204..bf2eacb80e 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -1,18 +1,13 @@ import unittest import os -import string -import random -import datetime import json import logging - -from nose.tools import eq_, ok_ +from nose.tools import eq_ from flask_appbuilder import SQLA from .sqla.models import Model1, Model2, insert_data from flask_appbuilder.models.sqla.filters import \ FilterGreater, FilterSmaller - log = logging.getLogger(__name__) MODEL1_DATA_SIZE = 10 @@ -80,6 +75,17 @@ class Model1FuncApi(ModelApi): 'field_string': 'Field String' } + class Model1ApiExcludeCols(ModelApi): + datamodel = SQLAInterface(Model1) + list_exclude_columns = [ + 'field_integer', + 'field_float', + 'field_date' + ] + show_exclude_columns = list_exclude_columns + edit_exclude_columns = list_exclude_columns + add_exclude_columns = list_exclude_columns + class Model1ApiOrder(ModelApi): datamodel = SQLAInterface(Model1) base_order = ('field_integer', 'desc') @@ -99,6 +105,7 @@ class Model1ApiFiltered(ModelApi): self.appbuilder.add_view_no_menu(Model1ApiFieldsInfo) self.appbuilder.add_view_no_menu(Model1ApiOrder) self.appbuilder.add_view_no_menu(Model1ApiFiltered) + self.appbuilder.add_view_no_menu(Model1ApiExcludeCols) class Model2Api(ModelApi): datamodel = SQLAInterface(Model2) @@ -183,6 +190,19 @@ def test_get_item_select_cols(self): }) eq_(rv.status_code, 200) + def test_get_item_excluded_cols(self): + """ + REST Api: Test get item with excluded columns + """ + client = self.app.test_client() + pk = 1 + rv = client.get('api/v1/model1apiexcludecols/{}/'.format(pk)) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['result'], { + 'field_string': 'test0' + }) + eq_(rv.status_code, 200) + def test_get_item_not_found(self): """ REST Api: Test get item not found @@ -370,6 +390,19 @@ def test_get_list_select_cols(self): ]) eq_(rv.status_code, 200) + def test_get_list_exclude_cols(self): + """ + REST Api: Test get list with excluded columns + """ + client = self.app.test_client() + uri = 'api/v1/model1apiexcludecols/' + rv = client.get(uri) + data = json.loads(rv.data.decode('utf-8')) + eq_(data['result'][0], { + 'id': 1, + 'field_string': 'test0' + }) + def test_get_list_base_filters(self): """ REST Api: Test get list with base filters @@ -659,6 +692,26 @@ def test_update_item_val_type(self): data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Not a valid string.') + def test_update_item_excluded_cols(self): + """ + REST Api: Test update item with excluded cols + """ + client = self.app.test_client() + pk = 1 + item = dict( + field_string="test_Put", + field_integer=1000 + ) + rv = client.put( + 'api/v1/model1apiexcludecols/{}'.format(pk), + json=item + ) + eq_(rv.status_code, 200) + model = self.db.session.query(Model1).get(pk) + eq_(model.field_integer, 0) + eq_(model.field_float, 0.0) + eq_(model.field_date, None) + def test_create_item(self): """ REST Api: Test create item @@ -720,6 +773,29 @@ def test_create_item_val_type(self): data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Not a valid string.') + def test_create_item_excluded_cols(self): + """ + REST Api: Test create with excluded columns + """ + client = self.app.test_client() + item = dict( + field_string="test{}".format(MODEL1_DATA_SIZE+1) + ) + rv = client.post('api/v1/model1apiexcludecols/', json=item) + eq_(rv.status_code, 201) + item = dict( + field_string="test{}".format(MODEL1_DATA_SIZE+2), + field_integer=MODEL1_DATA_SIZE+2 + ) + rv = client.post('api/v1/model1apiexcludecols/', json=item) + eq_(rv.status_code, 201) + model = (self.db.session.query(Model1) + .filter_by(field_string="test11") + .first()) + eq_(model.field_integer, None) + eq_(model.field_float, None) + eq_(model.field_date, None) + def test_get_list_col_function(self): """ REST Api: Test get list of objects with columns as functions @@ -733,11 +809,11 @@ def test_get_list_col_function(self): eq_(len(data['result']), self.model1api.page_size) for i in range(1, MODEL1_DATA_SIZE): item = data['result'][i - 1] - eq_(item['full_concat'], "{}.{}.{}.{}".format( + eq_( + item['full_concat'], "{}.{}.{}.{}".format( "test" + str(i - 1), i - 1, float(i - 1), None ) ) - From 6832fd665631f9d7711041df1bd5bcf42a0b69fd Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 15 Mar 2019 15:41:46 +0000 Subject: [PATCH 024/109] [style] Fix, PEP8 on security views --- flask_appbuilder/security/views.py | 311 +++++++++++++++++++---------- 1 file changed, 208 insertions(+), 103 deletions(-) diff --git a/flask_appbuilder/security/views.py b/flask_appbuilder/security/views.py index 6d5f7742bc..ac6c19ffe5 100644 --- a/flask_appbuilder/security/views.py +++ b/flask_appbuilder/security/views.py @@ -1,13 +1,12 @@ import re import datetime import logging -from flask import flash, redirect, session, url_for, request, g, make_response, jsonify, abort +from flask import flash, redirect, session, url_for, request, g, abort from werkzeug.security import generate_password_hash from wtforms import validators, PasswordField from wtforms.validators import EqualTo from flask_babel import lazy_gettext from flask_login import login_user, logout_user - from ..views import ModelView, SimpleFormView, expose from ..baseviews import BaseView from ..charts.views import DirectByChartView @@ -54,7 +53,10 @@ class PermissionViewModelView(ModelView): add_title = lazy_gettext('Add Permission on Views/Menus') edit_title = lazy_gettext('Edit Permission on Views/Menus') - label_columns = {'permission': lazy_gettext('Permission'), 'view_menu': lazy_gettext('View/Menu')} + label_columns = { + 'permission': lazy_gettext('Permission'), + 'view_menu': lazy_gettext('View/Menu') + } list_columns = ['permission', 'view_menu'] @@ -98,8 +100,9 @@ class UserInfoEditView(SimpleFormView): def form_get(self, form): item = self.appbuilder.sm.get_user_by_id(g.user.id) # fills the form generic solution - for key, value in form.data.items(): - if key == 'csrf_token': continue + for key, value in form.data.items(): + if key == 'csrf_token': + continue form_field = getattr(form, key) form_field.data = getattr(item, key) @@ -119,35 +122,43 @@ class UserModelView(ModelView): add_title = lazy_gettext('Add User') edit_title = lazy_gettext('Edit User') - label_columns = {'get_full_name': lazy_gettext('Full Name'), - 'first_name': lazy_gettext('First Name'), - 'last_name': lazy_gettext('Last Name'), - 'username': lazy_gettext('User Name'), - 'password': lazy_gettext('Password'), - 'active': lazy_gettext('Is Active?'), - 'email': lazy_gettext('Email'), - 'roles': lazy_gettext('Role'), - 'last_login': lazy_gettext('Last login'), - 'login_count': lazy_gettext('Login count'), - 'fail_login_count': lazy_gettext('Failed login count'), - 'created_on': lazy_gettext('Created on'), - 'created_by': lazy_gettext('Created by'), - 'changed_on': lazy_gettext('Changed on'), - 'changed_by': lazy_gettext('Changed by')} - - description_columns = {'first_name': lazy_gettext('Write the user first name or names'), - 'last_name': lazy_gettext('Write the user last name'), - 'username': lazy_gettext( - 'Username valid for authentication on DB or LDAP, unused for OID auth'), - 'password': lazy_gettext( - 'Please use a good password policy, this application does not check this for you'), - 'active': lazy_gettext('It\'s not a good policy to remove a user, just make it inactive'), - 'email': lazy_gettext('The user\'s email, this will also be used for OID auth'), - 'roles': lazy_gettext( - 'The user role on the application, this will associate with a list of permissions'), - 'conf_password': lazy_gettext('Please rewrite the user\'s password to confirm')} - - list_columns = ['first_name', 'last_name', 'username', 'email', 'active', 'roles'] + label_columns = { + 'get_full_name': lazy_gettext('Full Name'), + 'first_name': lazy_gettext('First Name'), + 'last_name': lazy_gettext('Last Name'), + 'username': lazy_gettext('User Name'), + 'password': lazy_gettext('Password'), + 'active': lazy_gettext('Is Active?'), + 'email': lazy_gettext('Email'), + 'roles': lazy_gettext('Role'), + 'last_login': lazy_gettext('Last login'), + 'login_count': lazy_gettext('Login count'), + 'fail_login_count': lazy_gettext('Failed login count'), + 'created_on': lazy_gettext('Created on'), + 'created_by': lazy_gettext('Created by'), + 'changed_on': lazy_gettext('Changed on'), + 'changed_by': lazy_gettext('Changed by') + } + + description_columns = { + 'first_name': lazy_gettext('Write the user first name or names'), + 'last_name': lazy_gettext('Write the user last name'), + 'username': lazy_gettext('Username valid for authentication on DB or LDAP, unused for OID auth'), + 'password': lazy_gettext('Please use a good password policy, this application does not check this for you'), + 'active': lazy_gettext('It\'s not a good policy to remove a user, just make it inactive'), + 'email': lazy_gettext('The user\'s email, this will also be used for OID auth'), + 'roles': lazy_gettext('The user role on the application, this will associate with a list of permissions'), + 'conf_password': lazy_gettext('Please rewrite the user\'s password to confirm') + } + + list_columns = [ + 'first_name', + 'last_name', + 'username', + 'email', + 'active', + 'roles' + ] show_fieldsets = [ (lazy_gettext('User info'), @@ -168,22 +179,46 @@ class UserModelView(ModelView): search_exclude_columns = ['password'] - add_columns = ['first_name', 'last_name', 'username', 'active', 'email', 'roles'] - edit_columns = ['first_name', 'last_name', 'username', 'active', 'email', 'roles'] + add_columns = [ + 'first_name', + 'last_name', + 'username', + 'active', + 'email', + 'roles'] + edit_columns = [ + 'first_name', + 'last_name', + 'username', + 'active', + 'email', + 'roles' + ] user_info_title = lazy_gettext("Your user information") @expose('/userinfo/') @has_access def userinfo(self): item = self.datamodel.get(g.user.id, self._base_filters) - widgets = self._get_show_widget(g.user.id, item, show_fieldsets=self.user_show_fieldsets) + widgets = self._get_show_widget( + g.user.id, + item, + show_fieldsets=self.user_show_fieldsets + ) self.update_redirect() - return self.render_template(self.show_template, - title=self.user_info_title, - widgets=widgets, - appbuilder=self.appbuilder) + return self.render_template( + self.show_template, + title=self.user_info_title, + widgets=widgets, + appbuilder=self.appbuilder + ) - @action('userinfoedit', lazy_gettext("Edit User"), "", "fa-edit", multiple=False) + @action( + 'userinfoedit', + lazy_gettext("Edit User"), + "", + "fa-edit", + multiple=False) def userinfoedit(self, item): return redirect(url_for(self.appbuilder.sm.userinfoeditview.__name__ + '.this_form_get')) @@ -230,60 +265,101 @@ class UserDBModelView(UserModelView): Override to implement your own custom view. Then override userdbmodelview property on SecurityManager """ - add_form_extra_fields = {'password': PasswordField(lazy_gettext('Password'), - description=lazy_gettext( - 'Please use a good password policy, this application does not check this for you'), - validators=[validators.DataRequired()], - widget=BS3PasswordFieldWidget()), - 'conf_password': PasswordField(lazy_gettext('Confirm Password'), - description=lazy_gettext( - 'Please rewrite the user\'s password to confirm'), - validators=[EqualTo('password', message=lazy_gettext( - 'Passwords must match'))], - widget=BS3PasswordFieldWidget())} - - add_columns = ['first_name', 'last_name', 'username', 'active', 'email', 'roles', 'password', 'conf_password'] + add_form_extra_fields = { + 'password': PasswordField( + lazy_gettext('Password'), + description=lazy_gettext('Please use a good password policy, this application does not check this for you'), + validators=[validators.DataRequired()], + widget=BS3PasswordFieldWidget() + ), + 'conf_password': PasswordField( + lazy_gettext('Confirm Password'), + description=lazy_gettext('Please rewrite the user\'s password to confirm'), + validators=[EqualTo('password', message=lazy_gettext('Passwords must match'))], + widget=BS3PasswordFieldWidget() + ) + } + + add_columns = [ + 'first_name', + 'last_name', + 'username', + 'active', + 'email', + 'roles', + 'password', + 'conf_password' + ] @expose('/show/', methods=['GET']) @has_access def show(self, pk): - actions = {} + actions = dict() actions['resetpasswords'] = self.actions.get('resetpasswords') item = self.datamodel.get(pk, self._base_filters) if not item: abort(404) widgets = self._get_show_widget(pk, item, actions=actions) self.update_redirect() - return self.render_template(self.show_template, - pk=pk, - title=self.show_title, - widgets=widgets, - appbuilder=self.appbuilder, - related_views=self._related_views) + return self.render_template( + self.show_template, + pk=pk, + title=self.show_title, + widgets=widgets, + appbuilder=self.appbuilder, + related_views=self._related_views + ) @expose('/userinfo/') @has_access def userinfo(self): - actions = {} + actions = dict() actions['resetmypassword'] = self.actions.get('resetmypassword') actions['userinfoedit'] = self.actions.get('userinfoedit') item = self.datamodel.get(g.user.id, self._base_filters) - widgets = self._get_show_widget(g.user.id, item, actions=actions, show_fieldsets=self.user_show_fieldsets) + widgets = self._get_show_widget( + g.user.id, + item, + actions=actions, + show_fieldsets=self.user_show_fieldsets + ) self.update_redirect() - return self.render_template(self.show_template, - title=self.user_info_title, - widgets=widgets, - appbuilder=self.appbuilder, + return self.render_template( + self.show_template, + title=self.user_info_title, + widgets=widgets, + appbuilder=self.appbuilder, ) - @action('resetmypassword', lazy_gettext("Reset my password"), "", "fa-lock", multiple=False) + @action( + 'resetmypassword', + lazy_gettext("Reset my password"), + "", + "fa-lock", + multiple=False + ) def resetmypassword(self, item): - return redirect(url_for(self.appbuilder.sm.resetmypasswordview.__name__ + '.this_form_get')) + return redirect( + url_for( + self.appbuilder.sm.resetmypasswordview.__name__ + '.this_form_get' + ) + ) - @action('resetpasswords', lazy_gettext("Reset Password"), "", "fa-lock", multiple=False) + @action( + 'resetpasswords', + lazy_gettext("Reset Password"), + "", + "fa-lock", + multiple=False + ) def resetpasswords(self, item): - return redirect(url_for(self.appbuilder.sm.resetpasswordview.__name__ + '.this_form_get', pk=item.id)) + return redirect( + url_for( + self.appbuilder.sm.resetpasswordview.__name__ + + '.this_form_get', pk=item.id + ) + ) def pre_update(self, item): item.changed_on = datetime.datetime.now() @@ -295,9 +371,10 @@ def pre_add(self, item): class UserStatsChartView(DirectByChartView): chart_title = lazy_gettext('User Statistics') - label_columns = {'username': lazy_gettext('User Name'), - 'login_count': lazy_gettext('Login count'), - 'fail_login_count': lazy_gettext('Failed login count') + label_columns = { + 'username': lazy_gettext('User Name'), + 'login_count': lazy_gettext('Login count'), + 'fail_login_count': lazy_gettext('Failed login count') } search_columns = UserModelView.search_columns @@ -325,11 +402,20 @@ class RoleModelView(ModelView): add_title = lazy_gettext('Add Role') edit_title = lazy_gettext('Edit Role') - label_columns = {'name': lazy_gettext('Name'), 'permissions': lazy_gettext('Permissions')} + label_columns = { + 'name': lazy_gettext('Name'), + 'permissions': lazy_gettext('Permissions') + } list_columns = ['name', 'permissions'] order_columns = ['name'] - @action("Copy Role", lazy_gettext('Copy Role'), lazy_gettext('Copy the selected roles?'), icon='fa-copy', single=False) + @action( + "Copy Role", + lazy_gettext('Copy Role'), + lazy_gettext('Copy the selected roles?'), + icon='fa-copy', + single=False + ) def copy_role(self, items): self.update_redirect() for item in items: @@ -346,7 +432,7 @@ class RegisterUserModelView(ModelView): base_permissions = ['can_list', 'can_show', 'can_delete'] list_title = lazy_gettext('List of Registration Requests') show_title = lazy_gettext('Show Registration') - list_columns = ['username','registration_date','email'] + list_columns = ['username', 'registration_date', 'email'] show_exclude_columns = ['password'] search_exclude_columns = ['password'] @@ -354,9 +440,7 @@ class RegisterUserModelView(ModelView): class AuthView(BaseView): route_base = '' login_template = '' - invalid_login_message = lazy_gettext('Invalid login. Please try again.') - title = lazy_gettext('Sign In') @expose('/login/', methods=['GET', 'POST']) @@ -378,16 +462,21 @@ def login(self): return redirect(self.appbuilder.get_url_for_index) form = LoginForm_db() if form.validate_on_submit(): - user = self.appbuilder.sm.auth_user_db(form.username.data, form.password.data) + user = self.appbuilder.sm.auth_user_db( + form.username.data, + form.password.data + ) if not user: flash(as_unicode(self.invalid_login_message), 'warning') return redirect(self.appbuilder.get_url_for_login) login_user(user, remember=False) return redirect(self.appbuilder.get_url_for_index) - return self.render_template(self.login_template, - title=self.title, - form=form, - appbuilder=self.appbuilder) + return self.render_template( + self.login_template, + title=self.title, + form=form, + appbuilder=self.appbuilder + ) class AuthLDAPView(AuthView): @@ -399,16 +488,21 @@ def login(self): return redirect(self.appbuilder.get_url_for_index) form = LoginForm_db() if form.validate_on_submit(): - user = self.appbuilder.sm.auth_user_ldap(form.username.data, form.password.data) + user = self.appbuilder.sm.auth_user_ldap( + form.username.data, + form.password.data + ) if not user: flash(as_unicode(self.invalid_login_message), 'warning') return redirect(self.appbuilder.get_url_for_login) login_user(user, remember=False) return redirect(self.appbuilder.get_url_for_index) - return self.render_template(self.login_template, - title=self.title, - form=form, - appbuilder=self.appbuilder) + return self.render_template( + self.login_template, + title=self.title, + form=form, + appbuilder=self.appbuilder + ) """ For Future Use, API Auth, must check howto keep REST stateless @@ -455,13 +549,17 @@ def login_handler(self): form = LoginForm_oid() if form.validate_on_submit(): session['remember_me'] = form.remember_me.data - return self.appbuilder.sm.oid.try_login(form.openid.data, ask_for=self.oid_ask_for, - ask_for_optional=self.oid_ask_for_optional) - return self.render_template(self.login_template, - title=self.title, - form=form, - providers=self.appbuilder.sm.openid_providers, - appbuilder=self.appbuilder + return self.appbuilder.sm.oid.try_login( + form.openid.data, + ask_for=self.oid_ask_for, + ask_for_optional=self.oid_ask_for_optional + ) + return self.render_template( + self.login_template, + title=self.title, + form=form, + providers=self.appbuilder.sm.openid_providers, + appbuilder=self.appbuilder ) @self.appbuilder.sm.oid.after_login @@ -487,7 +585,6 @@ def after_login(resp): class AuthOAuthView(AuthView): login_template = 'appbuilder/general/security/login_oauth.html' - @expose('/login/') @expose('/login/') @expose('/login//') @@ -497,17 +594,25 @@ def login(self, provider=None, register=None): log.debug("Already authenticated {0}".format(g.user)) return redirect(self.appbuilder.get_url_for_index) if provider is None: - return self.render_template(self.login_template, - providers = self.appbuilder.sm.oauth_providers, - title=self.title, - appbuilder=self.appbuilder) + return self.render_template( + self.login_template, + providers=self.appbuilder.sm.oauth_providers, + title=self.title, + appbuilder=self.appbuilder + ) else: log.debug("Going to call authorize for: {0}".format(provider)) try: if register: log.debug('Login to Register') session['register'] = True - return self.appbuilder.sm.oauth_remotes[provider].authorize(callback=url_for('.oauth_authorized',provider=provider, _external=True)) + return self.appbuilder.sm.oauth_remotes[provider].authorize( + callback=url_for( + '.oauth_authorized', + provider=provider, + _external=True + ) + ) except Exception as e: log.error("Error on OAuth authorize: {0}".format(e)) flash(as_unicode(self.invalid_login_message), 'warning') From 208bc027e4255ca2dd0623795997f7631105de78 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 15 Mar 2019 16:07:34 +0000 Subject: [PATCH 025/109] [style] Fix, PEP8 on security manager --- flask_appbuilder/security/manager.py | 220 +++++++++++++++++++-------- 1 file changed, 153 insertions(+), 67 deletions(-) diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index 6cc91c7ad6..d265e08cf2 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -8,19 +8,41 @@ from flask_login import LoginManager, current_user from flask_openid import OpenID from flask_babel import lazy_gettext as _ -from .views import AuthDBView, AuthOIDView, ResetMyPasswordView, AuthLDAPView, AuthOAuthView, AuthRemoteUserView, \ - ResetPasswordView, UserDBModelView, UserLDAPModelView, UserOIDModelView, UserOAuthModelView, UserRemoteUserModelView, \ - RoleModelView, PermissionViewModelView, ViewMenuModelView, PermissionModelView, UserStatsChartView, RegisterUserModelView, \ +from .views import ( + AuthDBView, + AuthOIDView, + ResetMyPasswordView, + AuthLDAPView, + AuthOAuthView, + AuthRemoteUserView, + ResetPasswordView, + UserDBModelView, + UserLDAPModelView, + UserOIDModelView, + UserOAuthModelView, + UserRemoteUserModelView, + RoleModelView, + PermissionViewModelView, + ViewMenuModelView, + PermissionModelView, + UserStatsChartView, + RegisterUserModelView, UserInfoEditView +) from .registerviews import RegisterUserDBView, RegisterUserOIDView, RegisterUserOAuthView from ..basemanager import BaseManager -from ..const import AUTH_OID, AUTH_DB, AUTH_LDAP, \ - AUTH_REMOTE_USER, AUTH_OAUTH, \ - LOGMSG_ERR_SEC_AUTH_LDAP, \ - LOGMSG_ERR_SEC_AUTH_LDAP_TLS, \ - LOGMSG_WAR_SEC_NO_USER, \ - LOGMSG_WAR_SEC_NOLDAP_OBJ, \ - LOGMSG_WAR_SEC_LOGIN_FAILED +from ..const import ( + AUTH_OID, + AUTH_DB, + AUTH_LDAP, + AUTH_REMOTE_USER, + AUTH_OAUTH, + LOGMSG_ERR_SEC_AUTH_LDAP, + LOGMSG_ERR_SEC_AUTH_LDAP_TLS, + LOGMSG_WAR_SEC_NO_USER, + LOGMSG_WAR_SEC_NOLDAP_OBJ, + LOGMSG_WAR_SEC_LOGIN_FAILED +) log = logging.getLogger(__name__) @@ -36,7 +58,8 @@ def add_permissions_view(self, base_permissions, view_menu): Adds a permission on a view menu to the backend :param base_permissions: - list of permissions from view (all exposed methods): 'can_add','can_edit' etc... + list of permissions from view (all exposed methods): + 'can_add','can_edit' etc... :param view_menu: name of the view or menu to add """ @@ -179,7 +202,9 @@ def __init__(self, appbuilder): # LDAP Config if self.auth_type == AUTH_LDAP: if 'AUTH_LDAP_SERVER' not in app.config: - raise Exception("No AUTH_LDAP_SERVER defined on config with AUTH_LDAP authentication type.") + raise Exception( + "No AUTH_LDAP_SERVER defined on config with AUTH_LDAP authentication type." + ) app.config.setdefault('AUTH_LDAP_SEARCH', '') app.config.setdefault('AUTH_LDAP_SEARCH_FILTER', '') app.config.setdefault('AUTH_LDAP_BIND_USER', '') @@ -224,7 +249,9 @@ def __init__(self, appbuilder): @property def get_url_for_registeruser(self): - return url_for('%s.%s' % (self.registeruser_view.endpoint, self.registeruser_view.default_view)) + return url_for( + '%s.%s' % (self.registeruser_view.endpoint, self.registeruser_view.default_view) + ) @property def get_user_datamodel(self): @@ -360,7 +387,8 @@ def wraps(provider, response=None): ret = f(self, provider, response=response) # Checks if decorator is well behaved and returns a dict as supposed. if not type(ret) == dict: - log.error("OAuth user info decorated function did not returned a dict, but: {0}".format(type(ret))) + log.error( + "OAuth user info decorated function did not returned a dict, but: {0}".format(type(ret))) return {} return ret self.oauth_user_info = wraps @@ -396,7 +424,7 @@ def set_oauth_session(self, provider, oauth_response): # Save users token on encrypted session cookie session['oauth'] = ( oauth_response[token_key], - oauth_response.get(token_secret,'') + oauth_response.get(token_secret, '') ) session['oauth_provider'] = provider @@ -417,30 +445,43 @@ def get_oauth_user_info(self, provider, resp): return {'username': "twitter_" + me.data.get('screen_name', '')} # for linkedin if provider == 'linkedin': - me = self.appbuilder.sm.oauth_remotes[provider].get('people/~:(id,email-address,first-name,last-name)?format=json') + me = self.appbuilder.sm.oauth_remotes[provider].get( + 'people/~:(id,email-address,first-name,last-name)?format=json' + ) log.debug("User info from Linkedin: {0}".format(me.data)) - return {'username': "linkedin_" + me.data.get('id', ''), + return { + 'username': "linkedin_" + me.data.get('id', ''), 'email': me.data.get('email-address', ''), 'first_name': me.data.get('firstName', ''), - 'last_name': me.data.get('lastName', '')} + 'last_name': me.data.get('lastName', '') + } # for Google if provider == 'google': me = self.appbuilder.sm.oauth_remotes[provider].get('userinfo') log.debug("User info from Google: {0}".format(me.data)) - return {'username': "google_" + me.data.get('id', ''), + return { + 'username': "google_" + me.data.get('id', ''), 'first_name': me.data.get('given_name', ''), 'last_name': me.data.get('family_name', ''), - 'email': me.data.get('email', '')} + 'email': me.data.get('email', '') + } # for Azure AD Tenant. Azure OAuth response contains JWT token which has user info. # JWT token needs to be base64 decoded. # https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-protocols-oauth-code if provider == 'azure': log.debug("Azure response received : {0}".format(resp)) - id_token=resp['id_token'] + id_token = resp['id_token'] log.debug(str(id_token)) - me=self._azure_jwt_token_parse(id_token) + me = self._azure_jwt_token_parse(id_token) log.debug("Parse JWT token : {0}".format(me)) - return { 'name' : me['name'] , 'email' : me['upn'], 'first_name' : me['given_name'], 'last_name' : me['family_name'], 'id' : me['oid'], 'username' : me['oid'] } + return { + 'name': me['name'], + 'email': me['upn'], + 'first_name': me['given_name'], + 'last_name': me['family_name'], + 'id': me['oid'], + 'username': me['oid'] + } else: return {} @@ -448,14 +489,13 @@ def _azure_parse_jwt(self, id_token): jwt_token_parts = r"^([^\.\s]*)\.([^\.\s]+)\.([^\.\s]*)$" matches = re.search(jwt_token_parts, id_token) if not matches or len(matches.groups()) < 3: - log.error( 'Unable to parse token.') + log.error('Unable to parse token.') return {} - return { 'header': matches.group(1), 'Payload': matches.group(2), 'Sig': matches.group(3) - } + } def _azure_jwt_token_parse(self, id_token): jwt_split_token = self._azure_parse_jwt(id_token) @@ -469,14 +509,13 @@ def _azure_jwt_token_parse(self, id_token): decoded_payload = base64.urlsafe_b64decode(payload_b64_string.encode('ascii')) if not decoded_payload: - log.error( 'Payload of id_token could not be base64 url decoded.') + log.error('Payload of id_token could not be base64 url decoded.') return jwt_decoded_payload = json.loads(decoded_payload.decode('utf-8')) return jwt_decoded_payload - def register_views(self): if self.auth_user_registration: if self.auth_type == AUTH_DB: @@ -515,37 +554,58 @@ def register_views(self): self.appbuilder.add_view_no_menu(self.auth_view) - self.user_view = self.appbuilder.add_view(self.user_view, "List Users", - icon="fa-user", label=_("List Users"), - category="Security", category_icon="fa-cogs", - category_label=_('Security')) + self.user_view = self.appbuilder.add_view( + self.user_view, "List Users", + icon="fa-user", label=_("List Users"), + category="Security", category_icon="fa-cogs", + category_label=_('Security') + ) - role_view = self.appbuilder.add_view(self.rolemodelview, "List Roles", - icon="fa-group", label=_('List Roles'), - category="Security", category_icon="fa-cogs") + role_view = self.appbuilder.add_view( + self.rolemodelview, + "List Roles", + icon="fa-group", + label=_('List Roles'), + category="Security", + category_icon="fa-cogs" + ) role_view.related_views = [self.user_view.__class__] - self.appbuilder.add_view(self.userstatschartview, - "User's Statistics", icon="fa-bar-chart-o", - label=_("User's Statistics"), - category="Security") - + self.appbuilder.add_view( + self.userstatschartview, + "User's Statistics", icon="fa-bar-chart-o", + label=_("User's Statistics"), + category="Security" + ) if self.auth_user_registration: - self.appbuilder.add_view(self.registerusermodelview, - "User's Statistics", icon="fa-user-plus", - label=_("User Registrations"), - category="Security") - + self.appbuilder.add_view( + self.registerusermodelview, + "User's Statistics", icon="fa-user-plus", + label=_("User Registrations"), + category="Security" + ) self.appbuilder.menu.add_separator("Security") - self.appbuilder.add_view(self.permissionmodelview, - "Base Permissions", icon="fa-lock", - label=_("Base Permissions"), category="Security") - self.appbuilder.add_view(self.viewmenumodelview, - "Views/Menus", icon="fa-list-alt", - label=_('Views/Menus'), category="Security") - self.appbuilder.add_view(self.permissionviewmodelview, - "Permission on Views/Menus", icon="fa-link", - label=_('Permission on Views/Menus'), category="Security") + self.appbuilder.add_view( + self.permissionmodelview, + "Base Permissions", + icon="fa-lock", + label=_("Base Permissions"), + category="Security" + ) + self.appbuilder.add_view( + self.viewmenumodelview, + "Views/Menus", + icon="fa-list-alt", + label=_('Views/Menus'), + category="Security" + ) + self.appbuilder.add_view( + self.permissionviewmodelview, + "Permission on Views/Menus", + icon="fa-link", + label=_('Permission on Views/Menus'), + category="Security" + ) def create_db(self): """ @@ -628,16 +688,27 @@ def _search_ldap(self, ldap, con, username): if self.auth_ldap_append_domain: username = username + '@' + self.auth_ldap_append_domain if self.auth_ldap_search_filter: - filter_str = "(&%s(%s=%s))" % (self.auth_ldap_search_filter, self.auth_ldap_uid_field, username) + filter_str = \ + "(&%s(%s=%s))" % ( + self.auth_ldap_search_filter, + self.auth_ldap_uid_field, username + ) else: - filter_str = "(%s=%s)" % (self.auth_ldap_uid_field, username) - user = con.search_s(self.auth_ldap_search, - ldap.SCOPE_SUBTREE, - filter_str, - [self.auth_ldap_firstname_field, - self.auth_ldap_lastname_field, - self.auth_ldap_email_field - ]) + filter_str = \ + "(%s=%s)" % ( + self.auth_ldap_uid_field, + username + ) + user = con.search_s( + self.auth_ldap_search, + ldap.SCOPE_SUBTREE, + filter_str, + [ + self.auth_ldap_firstname_field, + self.auth_ldap_lastname_field, + self.auth_ldap_email_field + ] + ) if user: if not user[0][0]: return None @@ -754,9 +825,21 @@ def auth_user_ldap(self, username, password): if self.auth_user_registration and user is None: user = self.add_user( username=username, - first_name=self.ldap_extract(ldap_user_info, self.auth_ldap_firstname_field, username), - last_name=self.ldap_extract(ldap_user_info, self.auth_ldap_lastname_field, username), - email=self.ldap_extract(ldap_user_info, self.auth_ldap_email_field, username + '@email.notfound'), + first_name=self.ldap_extract( + ldap_user_info, + self.auth_ldap_firstname_field, + username + ), + last_name=self.ldap_extract( + ldap_user_info, + self.auth_ldap_lastname_field, + username + ), + email=self.ldap_extract( + ldap_user_info, + self.auth_ldap_email_field, + username + '@email.notfound' + ), role=self.find_role(self.auth_user_registration_role) ) @@ -775,6 +858,7 @@ def auth_user_oid(self, email): """ OpenID user Authentication + :param email: user's email to authenticate :type self: User model """ user = self.find_user(email=email) @@ -789,6 +873,7 @@ def auth_user_remote_user(self, username): """ REMOTE_USER user Authentication + :param username: user's username for remote auth :type self: User model """ user = self.find_user(username=username) @@ -880,7 +965,8 @@ def _has_view_access(self, user, permission_name, view_name): permissions = role.permissions if permissions: for permission in permissions: - if (view_name == permission.view_menu.name) and (permission_name == permission.permission.name): + if ((view_name == permission.view_menu.name) + and (permission_name == permission.permission.name)): return True return False From 4ec18a5c96381996eff0b8c9a52eca7b13b11320 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sat, 16 Mar 2019 18:01:56 +0000 Subject: [PATCH 026/109] [api] authorization and authentication using JWT --- flask_appbuilder/api.py | 87 +++--- flask_appbuilder/base.py | 3 + flask_appbuilder/security/decorators.py | 78 ++++- flask_appbuilder/security/manager.py | 4 + flask_appbuilder/security/views.py | 53 ++++ flask_appbuilder/tests/test_api.py | 390 ++++++++++++++++++++---- flask_appbuilder/tests/test_base.py | 9 +- rtd_requirements.txt | 1 + setup.py | 1 + 9 files changed, 526 insertions(+), 100 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 735f2b5c73..1299c99f48 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -1,9 +1,9 @@ import re import logging import functools -from flask import Blueprint, abort, make_response, jsonify, request +from flask import Blueprint, make_response, jsonify, request from flask_babel import lazy_gettext as _ -from .security.decorators import permission_name +from .security.decorators import permission_name, jwt_has_access from marshmallow import ValidationError from ._compat import as_unicode @@ -113,10 +113,13 @@ def wraps(self, *args, **kwargs): re_match = re.findall('(.*):(.*):(.*)', value) for _item_re_match in re_match: if _item_re_match and len(_item_re_match) == 3: - filters.append({"col_name": _item_re_match[0], - "operator": _item_re_match[1], - "value": _item_re_match[2], - }) + filters.append( + { + "col_name": _item_re_match[0], + "operator": _item_re_match[1], + "value": _item_re_match[2], + } + ) else: log.warning("Bar filter args {} ".format(_item_re_match)) kwargs['filters'] = filters @@ -250,6 +253,27 @@ def get_init_inner_views(self, views): """ pass + @staticmethod + def response(code, **kwargs): + _ret_json = jsonify(kwargs) + resp = make_response(_ret_json, code) + resp.headers['Content-Type'] = "application/json; charset=utf-8" + return resp + + def response_400(self, message=None): + message = message or "Arguments are not correct" + return self.response(400, **{"message": message}) + + def response_401(self): + return self.response(401, **{"message": "Not authorized"}) + + def response_404(self): + return self.response(404, **{"message": "Not found"}) + + def response_500(self, message=None): + message = message or "Internal error" + return self.response(500, **{"message": message}) + class BaseModelApi(BaseApi): datamodel = None @@ -630,6 +654,7 @@ def _get_fields_info(self, cols, model_schema, filter_rel_fields): ] @expose('/info', methods=['GET']) + @jwt_has_access @permission_name('get') def info(self): # Get info from add fields @@ -653,7 +678,7 @@ def info(self): {'name': as_unicode(flt.name), 'operator': flt.arg_name} for flt in dict_filters[col] ] - return self._api_json_response( + return self.response( 200, filters=search_filters, add_fields=_add_fields, @@ -662,6 +687,7 @@ def info(self): @expose('/', methods=['GET']) @expose('//', methods=['GET']) + @jwt_has_access @permission_name('get') def get(self, pk=None): if not pk: @@ -669,65 +695,68 @@ def get(self, pk=None): return self._get_item(pk) @expose('/', methods=['POST']) + @jwt_has_access @permission_name('post') def post(self): try: item = self.add_model_schema.load(request.json) except ValidationError as err: - return self._api_json_response(400, **{'message': err.messages}) + return self.response(400, **{'message': err.messages}) else: self.pre_add(item.data) if self.datamodel.add(item.data): self.post_add(item.data) - return self._api_json_response( + return self.response( 201, **{'result': self.add_model_schema.dump(item.data, many=False).data} ) else: - return self._api_json_500() + return self.response_500() @expose('/', methods=['PUT']) + @jwt_has_access @permission_name('put') def put(self, pk): item = self.datamodel.get(pk, self._base_filters) if not item: - return self._api_json_404() + return self.response_404() else: try: item = self.edit_model_schema.load(request.json, instance=item) except ValidationError as err: - return self._api_json_response(400, **{'message': err.messages}) + return self.response(400, **{'message': err.messages}) else: self.pre_update(item.data) if self.datamodel.edit(item.data): self.post_add(item) self.post_update(item) - return self._api_json_response( + return self.response( 200, **{'result': self.edit_model_schema.dump(item.data, many=False).data} ) else: - return self._api_json_500() + return self.response_500() @expose('/', methods=['DELETE']) + @jwt_has_access @permission_name('delete') def delete(self, pk): item = self.datamodel.get(pk, self._base_filters) if not item: - return self._api_json_404() + return self.response_404() else: self.pre_delete(item) if self.datamodel.delete(item): self.post_delete(item) - return self._api_json_response(200, **{'message': 'OK'}) + return self.response(200, **{'message': 'OK'}) else: - return self._api_json_500() + return self.response_500() @select_col_args def _get_item(self, pk, select_cols=None): item = self.datamodel.get(pk, self._base_filters) if not item: - return self._api_json_404() + return self.response_404() select_cols = select_cols or [] _pruned_select_cols = [col for col in select_cols if col in self.show_columns] if _pruned_select_cols: @@ -736,7 +765,7 @@ def _get_item(self, pk, select_cols=None): else: _show_columns = self.show_columns _show_model_schema = self.show_model_schema - return self._api_json_response( + return self.response( 200, pk=pk, label_columns=self._label_columns_json(_show_columns), include_columns=_show_columns, @@ -779,7 +808,7 @@ def _get_list(self, filters=None, order_column=None, order_direction=None, order_col for order_col in self.order_columns if order_col in _list_columns ] - return self._api_json_response( + return self.response( 200, label_columns=self._label_columns_json(_list_columns), list_columns=_list_columns, @@ -795,24 +824,6 @@ def _get_list(self, filters=None, order_column=None, order_direction=None, HELPER FUNCTIONS ------------------------------------------------ """ - @staticmethod - def _api_json_response(code, **kwargs): - _ret_json = jsonify(kwargs) - response = make_response(_ret_json, code) - response.headers['Content-Type'] = "application/json; charset=utf-8" - return response - - def _api_json_400(self, message=None): - message = message or "Arguments are not correct" - return self._api_json_response(400, **{"message": message}) - - def _api_json_404(self): - return self._api_json_response(404, **{"message": "Not found"}) - - def _api_json_500(self, message=None): - message = message or "Internal error" - return self._api_json_response(500, **{"message": message}) - def _description_columns_json(self, cols=None): """ Prepares dict with col descriptions to be JSON serializable diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index 4a29f6d111..8874d976b9 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -2,6 +2,7 @@ from flask import Blueprint, url_for, current_app from flask_marshmallow import Marshmallow +from flask_jwt_extended import JWTManager from .views import IndexView, UtilView from .filters import TemplateFilters from .menu import Menu @@ -135,6 +136,7 @@ def __init__(self, app=None, self.app = app self.marshmallow = Marshmallow() + self.jwt_manager = JWTManager() if app is not None: self.init_app(app, session) @@ -166,6 +168,7 @@ def init_app(self, app, session): self._add_addon_views() self._add_menu_permissions() self.marshmallow.init_app(self.app) + self.jwt_manager.init_app(self.app) if not self.app: for baseview in self.baseviews: # instantiate the views and add session diff --git a/flask_appbuilder/security/decorators.py b/flask_appbuilder/security/decorators.py index b51e41a102..83e37e4a8a 100644 --- a/flask_appbuilder/security/decorators.py +++ b/flask_appbuilder/security/decorators.py @@ -2,12 +2,54 @@ import functools from flask import flash, redirect, url_for, make_response, jsonify +from flask_jwt_extended import verify_jwt_in_request, get_jwt_identity +from flask_login import login_user from .._compat import as_unicode from ..const import LOGMSG_ERR_SEC_ACCESS_DENIED, FLAMSG_ERR_SEC_ACCESS_DENIED, PERMISSION_PREFIX log = logging.getLogger(__name__) +def jwt_has_access(f): + """ + Use this decorator to enable granular security permissions + to your API methods. + Permissions will be associated to a role, and roles are associated to users. + + By default the permission's name is the methods name. + """ + if hasattr(f, '_permission_name'): + permission_str = "{}{}".format( + PERMISSION_PREFIX, + f._permission_name + ) + else: + permission_str = "{}{}".format( + PERMISSION_PREFIX, + f.__name__ + ) + + def wraps(self, *args, **kwargs): + if self.appbuilder.sm.is_item_public( + permission_str, + self.__class__.__name__ + ): + return f(self, *args, **kwargs) + verify_jwt_in_request() + login_user(self.appbuilder.sm.get_user_by_id(int(get_jwt_identity()))) + if self.appbuilder.sm.has_access( + permission_str, + self.__class__.__name__ + ): + return f(self, *args, **kwargs) + else: + log.warning( + LOGMSG_ERR_SEC_ACCESS_DENIED.format(permission_str, self.__class__.__name__) + ) + return self.response_401() + return functools.update_wrapper(wraps, f) + + def has_access(f): """ Use this decorator to enable granular security permissions to your methods. @@ -22,12 +64,21 @@ def has_access(f): def wraps(self, *args, **kwargs): permission_str = PERMISSION_PREFIX + f._permission_name - if self.appbuilder.sm.has_access(permission_str, self.__class__.__name__): + if self.appbuilder.sm.has_access( + permission_str, + self.__class__.__name__ + ): return f(self, *args, **kwargs) else: - log.warning(LOGMSG_ERR_SEC_ACCESS_DENIED.format(permission_str, self.__class__.__name__)) + log.warning( + LOGMSG_ERR_SEC_ACCESS_DENIED.format(permission_str, self.__class__.__name__) + ) flash(as_unicode(FLAMSG_ERR_SEC_ACCESS_DENIED), "danger") - return redirect(url_for(self.appbuilder.sm.auth_view.__class__.__name__ + ".login")) + return redirect( + url_for( + self.appbuilder.sm.auth_view.__class__.__name__ + ".login" + ) + ) f._permission_name = permission_str return functools.update_wrapper(wraps, f) @@ -48,15 +99,26 @@ def has_access_api(f): def wraps(self, *args, **kwargs): permission_str = PERMISSION_PREFIX + f._permission_name - if self.appbuilder.sm.has_access(permission_str, self.__class__.__name__): + if self.appbuilder.sm.has_access( + permission_str, + self.__class__.__name__ + ): return f(self, *args, **kwargs) else: - log.warning(LOGMSG_ERR_SEC_ACCESS_DENIED.format(permission_str, self.__class__.__name__)) - response = make_response(jsonify({'message': str(FLAMSG_ERR_SEC_ACCESS_DENIED), - 'severity': 'danger'}), 401) + log.warning( + LOGMSG_ERR_SEC_ACCESS_DENIED.format(permission_str, self.__class__.__name__) + ) + response = make_response( + jsonify( + { + 'message': str(FLAMSG_ERR_SEC_ACCESS_DENIED), + 'severity': 'danger' + } + ), + 401 + ) response.headers['Content-Type'] = "application/json" return response - return redirect(url_for(self.appbuilder.sm.auth_view.__class__.__name__ + ".login")) f._permission_name = permission_str return functools.update_wrapper(wraps, f) diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index d265e08cf2..1540f5868a 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -9,6 +9,7 @@ from flask_openid import OpenID from flask_babel import lazy_gettext as _ from .views import ( + SecurityApi, AuthDBView, AuthOIDView, ResetMyPasswordView, @@ -517,6 +518,9 @@ def _azure_jwt_token_parse(self, id_token): return jwt_decoded_payload def register_views(self): + + self.appbuilder.add_view_no_menu(SecurityApi) + if self.auth_user_registration: if self.auth_type == AUTH_DB: self.registeruser_view = self.registeruserdbview() diff --git a/flask_appbuilder/security/views.py b/flask_appbuilder/security/views.py index ac6c19ffe5..c834443fcc 100644 --- a/flask_appbuilder/security/views.py +++ b/flask_appbuilder/security/views.py @@ -7,7 +7,9 @@ from wtforms.validators import EqualTo from flask_babel import lazy_gettext from flask_login import login_user, logout_user +from flask_jwt_extended import create_access_token from ..views import ModelView, SimpleFormView, expose +from ..api import BaseApi from ..baseviews import BaseView from ..charts.views import DirectByChartView from ..fieldwidgets import BS3PasswordFieldWidget @@ -20,6 +22,57 @@ log = logging.getLogger(__name__) +class SecurityApi(BaseApi): + + route_base = '/api/v1/security' + + @expose('/login', methods=['POST']) + def login(self): + """ + Login endpoint for the API returns a JWT + :return: + """ + # LOGIN_DB + # LDAP + # REMOTE USER ( is this possible secure? ) + # OAUTH ( is this done on the backend? ) + # AUTH0 ( trusted JWT we need a key to trust ) + + # Study asymmetric crypto option, good for AUTH0 + # Study refresh tokens + # https://flask-jwt-extended.readthedocs.io/en/latest/refresh_tokens.html#refresh-tokens + # https://tools.ietf.org/html/rfc7519 + # https://auth0.com/blog/json-web-token-signing-algorithms-overview/ + + print("LOGIN PAYLOAD {}".format(request.json)) + username = request.json.get('username', None) + password = request.json.get('password', None) + provider = request.json.get('provider', None) + if not username or not password or not provider: + return self.response_400(message="Missing required parameter") + # AUTH + if provider == 'db': + user = self.appbuilder.sm.auth_user_db( + username, + password + ) + elif provider == 'ldap': + user = self.appbuilder.sm.auth_user_ldap( + username, + password + ) + else: + return self.response_400( + message="Provider {} not supported".format(provider) + ) + if not user: + return self.response_401() + + # Identity can be any data that is json serializable + access_token = create_access_token(identity=user.id) + return self.response(200, access_token=access_token) + + class PermissionModelView(ModelView): route_base = '/permissions' base_permissions = ['can_list'] diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index bf2eacb80e..341b29291e 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -12,6 +12,8 @@ MODEL1_DATA_SIZE = 10 MODEL2_DATA_SIZE = 10 +USERNAME = "testadmin" +PASSWORD = "password" class FlaskTestCase(unittest.TestCase): @@ -29,7 +31,7 @@ def setUp(self): self.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False self.db = SQLA(self.app) - self.appbuilder = AppBuilder(self.app, self.db.session, update_perms=False) + self.appbuilder = AppBuilder(self.app, self.db.session) # Create models and insert data insert_data(self.db.session, MODEL1_DATA_SIZE) @@ -136,20 +138,78 @@ class Model2ApiFilteredRelFields(ModelApi): self.appbuilder.add_view_no_menu(Model2Api) self.model2apifilteredrelfields = Model2ApiFilteredRelFields self.appbuilder.add_view_no_menu(Model2ApiFilteredRelFields) + role_admin = self.appbuilder.sm.find_role('Admin') + self.appbuilder.sm.add_user( + USERNAME, + 'admin', + 'user', + 'admin@fab.org', + role_admin, + PASSWORD + ) def tearDown(self): self.appbuilder = None self.app = None self.db = None + @staticmethod + def auth_client_get(client, token, uri): + return client.get( + uri, + headers={"Authorization": "Bearer {}".format(token)} + ) + + @staticmethod + def auth_client_delete(client, token, uri): + return client.delete( + uri, + headers={"Authorization": "Bearer {}".format(token)} + ) + + @staticmethod + def auth_client_put(client, token, uri, json): + return client.put( + uri, + json=json, + headers={"Authorization": "Bearer {}".format(token)} + ) + + @staticmethod + def auth_client_post(client, token, uri, json): + return client.post( + uri, + json=json, + headers={"Authorization": "Bearer {}".format(token)} + ) + + @staticmethod + def login(client, username, password): + # Login with default admin + rv = client.post( + 'api/v1/security/login', + data=json.dumps(dict( + username=username, + password=password, + provider="db" + )), + content_type='application/json' + ) + log.fatal("FATAL {}".format(rv.data)) + return json.loads(rv.data.decode('utf-8')).get("access_token") + def test_get_item(self): """ REST Api: Test get item """ client = self.app.test_client() - + token = self.login(client, USERNAME, PASSWORD) for i in range(1, MODEL1_DATA_SIZE): - rv = client.get('api/v1/model1api/{}/'.format(i)) + rv = self.auth_client_get( + client, + token, + 'api/v1/model1api/{}/'.format(i) + ) data = json.loads(rv.data.decode('utf-8')) eq_(rv.status_code, 200) self.assert_get_item(rv, data, i - 1) @@ -177,9 +237,14 @@ def test_get_item_select_cols(self): REST Api: Test get item with select columns """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) for i in range(1, MODEL1_DATA_SIZE): - rv = client.get('api/v1/model1api/{}/?_c_=field_integer'.format(i)) + rv = self.auth_client_get( + client, + token, + 'api/v1/model1api/{}/?_c_=field_integer'.format(i) + ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'], {'field_integer': i - 1}) eq_(data['description_columns'], { @@ -195,8 +260,14 @@ def test_get_item_excluded_cols(self): REST Api: Test get item with excluded columns """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + pk = 1 - rv = client.get('api/v1/model1apiexcludecols/{}/'.format(pk)) + rv = self.auth_client_get( + client, + token, + 'api/v1/model1apiexcludecols/{}/'.format(pk) + ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'], { 'field_string': 'test0' @@ -208,8 +279,14 @@ def test_get_item_not_found(self): REST Api: Test get item not found """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + pk = 11 - rv = client.get('api/v1/model1api/{}/'.format(pk)) + rv = self.auth_client_get( + client, + token, + 'api/v1/model1api/{}/'.format(pk) + ) eq_(rv.status_code, 404) def test_get_item_base_filters(self): @@ -217,14 +294,23 @@ def test_get_item_base_filters(self): REST Api: Test get item with base filters """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + # We can't get a base filtered item pk = 1 - rv = client.get('api/v1/model1apifiltered/{}/'.format(pk)) + rv = self.auth_client_get( + client, + token, + 'api/v1/model1apifiltered/{}/'.format(pk) + ) eq_(rv.status_code, 404) - client = self.app.test_client() # This one is ok pk=4 field_integer=3 2>3<4 pk = 4 - rv = client.get('api/v1/model1apifiltered/{}/'.format(pk)) + rv = self.auth_client_get( + client, + token, + 'api/v1/model1apifiltered/{}/'.format(pk) + ) eq_(rv.status_code, 200) def test_get_item_rel_field(self): @@ -232,9 +318,15 @@ def test_get_item_rel_field(self): REST Api: Test get item with with related fields """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + # We can't get a base filtered item pk = 1 - rv = client.get('api/v1/model2api/{}/'.format(pk)) + rv = self.auth_client_get( + client, + token, + 'api/v1/model2api/{}/'.format(pk) + ) data = json.loads(rv.data.decode('utf-8')) eq_(rv.status_code, 200) eq_(data['result'], {'group': 1}) @@ -244,8 +336,13 @@ def test_get_list(self): REST Api: Test get list """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) - rv = client.get('api/v1/model1api/') + rv = self.auth_client_get( + client, + token, + 'api/v1/model1api/' + ) data = json.loads(rv.data.decode('utf-8')) # Tests count property eq_(data['count'], MODEL1_DATA_SIZE) @@ -269,9 +366,14 @@ def test_get_list_order(self): REST Api: Test get list order params """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) # test string order asc - rv = client.get('api/v1/model1api/?_o_=field_string:asc') + rv = self.auth_client_get( + client, + token, + 'api/v1/model1api/?_o_=field_string:asc' + ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { 'field_date': None, @@ -281,7 +383,11 @@ def test_get_list_order(self): }) eq_(rv.status_code, 200) # test string order desc - rv = client.get('api/v1/model1api/?_o_=field_string:desc') + rv = self.auth_client_get( + client, + token, + 'api/v1/model1api/?_o_=field_string:desc' + ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { 'field_date': None, @@ -296,9 +402,14 @@ def test_get_list_base_order(self): REST Api: Test get list with base order """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) # test string order asc - rv = client.get('api/v1/model1apiorder/') + rv = self.auth_client_get( + client, + token, + 'api/v1/model1apiorder/' + ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { 'id': MODEL1_DATA_SIZE, @@ -308,7 +419,12 @@ def test_get_list_base_order(self): 'field_string': "test{}".format(MODEL1_DATA_SIZE - 1) }) # Test override - rv = client.get('api/v1/model1apiorder/?_o_=field_integer:asc') + rv = self.auth_client_get( + client, + token, + 'api/v1/model1apiorder/?_o_=field_integer:asc' + ) + data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { 'id': 1, @@ -324,10 +440,15 @@ def test_get_list_page(self): """ page_size = 5 client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) # test page zero uri = 'api/v1/model1api/?_p_={}:0&_o_=field_integer:asc'.format(page_size) - rv = client.get(uri) + rv = self.auth_client_get( + client, + token, + uri + ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { 'field_date': None, @@ -339,7 +460,11 @@ def test_get_list_page(self): eq_(len(data['result']), page_size) # test page zero uri = 'api/v1/model1api/?_p_={}:1&_o_=field_integer:asc'.format(page_size) - rv = client.get(uri) + rv = self.auth_client_get( + client, + token, + uri + ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { 'field_date': None, @@ -355,10 +480,16 @@ def test_get_list_filters(self): REST Api: Test get list filter params """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + filter_value = 5 # test string order asc uri = 'api/v1/model1api/?_f_0=field_integer:gt:{}&_o_=field_integer:asc'.format(filter_value) - rv = client.get(uri) + rv = self.auth_client_get( + client, + token, + uri + ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { 'field_date': None, @@ -373,8 +504,14 @@ def test_get_list_select_cols(self): REST Api: Test get list with selected columns """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + uri = 'api/v1/model1api/?_c_=field_integer&_o_=field_integer:asc' - rv = client.get(uri) + rv = self.auth_client_get( + client, + token, + uri + ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { 'field_integer': 0, @@ -395,8 +532,14 @@ def test_get_list_exclude_cols(self): REST Api: Test get list with excluded columns """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + uri = 'api/v1/model1apiexcludecols/' - rv = client.get(uri) + rv = self.auth_client_get( + client, + token, + uri + ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { 'id': 1, @@ -408,8 +551,14 @@ def test_get_list_base_filters(self): REST Api: Test get list with base filters """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + uri = 'api/v1/model1apifiltered/?_o_=field_integer:asc' - rv = client.get(uri) + rv = self.auth_client_get( + client, + token, + uri + ) data = json.loads(rv.data.decode('utf-8')) expected_result = [ { @@ -427,8 +576,13 @@ def test_info_filters(self): REST Api: Test info filters """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) uri = 'api/v1/model1api/info' - rv = client.get(uri) + rv = self.auth_client_get( + client, + token, + uri + ) data = json.loads(rv.data.decode('utf-8')) expected_filters = { 'field_date': [ @@ -467,8 +621,14 @@ def test_info_fields(self): REST Api: Test info fields (add, edit) """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + uri = 'api/v1/model1apifieldsinfo/info' - rv = client.get(uri) + rv = self.auth_client_get( + client, + token, + uri + ) data = json.loads(rv.data.decode('utf-8')) expect_add_fields = [ { @@ -513,17 +673,23 @@ def test_info_fields_rel_field(self): REST Api: Test info fields with related fields """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + uri = 'api/v1/model2api/info' - rv = client.get(uri) + rv = self.auth_client_get( + client, + token, + uri + ) data = json.loads(rv.data.decode('utf-8')) expected_rel_add_field = { - 'description': '', - 'label': 'Group', - 'name': 'group', - 'required': False, - 'type': 'Related', - 'values': [] - } + 'description': '', + 'label': 'Group', + 'name': 'group', + 'required': False, + 'type': 'Related', + 'values': [] + } for i in range(MODEL1_DATA_SIZE): expected_rel_add_field['values'].append( { @@ -541,8 +707,13 @@ def test_info_fields_rel_filtered_field(self): related fields """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) uri = 'api/v1/model2apifilteredrelfields/info' - rv = client.get(uri) + rv = self.auth_client_get( + client, + token, + uri + ) data = json.loads(rv.data.decode('utf-8')) expected_rel_add_field = { 'description': '', @@ -569,8 +740,15 @@ def test_delete_item(self): REST Api: Test delete item """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + pk = 2 - rv = client.delete('api/v1/model1api/{}'.format(pk)) + uri = 'api/v1/model1api/{}'.format(pk) + rv = self.auth_client_delete( + client, + token, + uri + ) eq_(rv.status_code, 200) model = self.db.session.query(Model1).get(pk) eq_(model, None) @@ -580,8 +758,15 @@ def test_delete_item_not_found(self): REST Api: Test delete item not found """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + pk = 11 - rv = client.delete('api/v1/model1api/{}'.format(pk)) + uri = 'api/v1/model1api/{}'.format(pk) + rv = self.auth_client_delete( + client, + token, + uri + ) eq_(rv.status_code, 404) def test_delete_item_base_filters(self): @@ -589,9 +774,15 @@ def test_delete_item_base_filters(self): REST Api: Test delete item with base filters """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) # Try to delete a filtered item pk = 1 - rv = client.delete('api/v1/model1apifiltered/{}'.format(pk)) + uri = 'api/v1/model1apifiltered/{}'.format(pk) + rv = self.auth_client_delete( + client, + token, + uri + ) eq_(rv.status_code, 404) def test_update_item(self): @@ -599,13 +790,20 @@ def test_update_item(self): REST Api: Test update item """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) pk = 3 item = dict( field_string="test_Put", field_integer=0, field_float=0.0 ) - rv = client.put('api/v1/model1api/{}'.format(pk), json=item) + uri = 'api/v1/model1api/{}'.format(pk) + rv = self.auth_client_put( + client, + token, + uri, + item + ) eq_(rv.status_code, 200) model = self.db.session.query(Model1).get(pk) eq_(model.field_string, "test_Put") @@ -617,22 +815,34 @@ def test_update_item_base_filters(self): REST Api: Test update item with base filters """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) pk = 4 item = dict( field_string="test_Put", field_integer=3, field_float=3.0 ) - rv = client.put('api/v1/model1apifiltered/{}'.format(pk), json=item) + uri = 'api/v1/model1apifiltered/{}'.format(pk) + rv = self.auth_client_put( + client, + token, + uri, + item + ) eq_(rv.status_code, 200) model = self.db.session.query(Model1).get(pk) eq_(model.field_string, "test_Put") eq_(model.field_integer, 3) eq_(model.field_float, 3.0) # We can't update an item that is base filtered - client = self.app.test_client() pk = 1 - rv = client.put('api/v1/model1apifiltered/{}'.format(pk), json=item) + uri = 'api/v1/model1apifiltered/{}'.format(pk) + rv = self.auth_client_put( + client, + token, + uri, + item + ) eq_(rv.status_code, 404) def test_update_item_not_found(self): @@ -640,13 +850,20 @@ def test_update_item_not_found(self): REST Api: Test update item not found """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) pk = 11 item = dict( field_string="test_Put", field_integer=0, field_float=0.0 ) - rv = client.put('api/v1/model1api/{}'.format(pk), json=item) + uri = 'api/v1/model1api/{}'.format(pk) + rv = self.auth_client_put( + client, + token, + uri, + item + ) eq_(rv.status_code, 404) def test_update_val_size(self): @@ -654,6 +871,7 @@ def test_update_val_size(self): REST Api: Test update validate size """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) pk = 1 field_string = 'a' * 51 item = dict( @@ -661,7 +879,13 @@ def test_update_val_size(self): field_integer=11, field_float=11.0 ) - rv = client.put('api/v1/model1api/{}'.format(pk), json=item) + uri = 'api/v1/model1api/{}'.format(pk) + rv = self.auth_client_put( + client, + token, + uri, + item + ) eq_(rv.status_code, 400) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Longer than maximum length 50.') @@ -671,13 +895,20 @@ def test_update_item_val_type(self): REST Api: Test update validate type """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) pk = 1 item = dict( field_string="test11", field_integer="test11", field_float=11.0 ) - rv = client.put('api/v1/model1api/{}'.format(pk), json=item) + uri = 'api/v1/model1api/{}'.format(pk) + rv = self.auth_client_put( + client, + token, + uri, + item + ) eq_(rv.status_code, 400) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_integer'][0], 'Not a valid integer.') @@ -687,7 +918,12 @@ def test_update_item_val_type(self): field_integer=11, field_float=11.0 ) - rv = client.post('api/v1/model1api/', json=item) + rv = self.auth_client_put( + client, + token, + uri, + item + ) eq_(rv.status_code, 400) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Not a valid string.') @@ -697,14 +933,18 @@ def test_update_item_excluded_cols(self): REST Api: Test update item with excluded cols """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) pk = 1 item = dict( field_string="test_Put", field_integer=1000 ) - rv = client.put( - 'api/v1/model1apiexcludecols/{}'.format(pk), - json=item + uri = 'api/v1/model1apiexcludecols/{}'.format(pk) + rv = self.auth_client_put( + client, + token, + uri, + item ) eq_(rv.status_code, 200) model = self.db.session.query(Model1).get(pk) @@ -717,13 +957,20 @@ def test_create_item(self): REST Api: Test create item """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) item = dict( field_string="test11", field_integer=11, field_float=11.0, field_date=None ) - rv = client.post('api/v1/model1api/', json=item) + uri = 'api/v1/model1api/' + rv = self.auth_client_post( + client, + token, + uri, + item + ) data = json.loads(rv.data.decode('utf-8')) eq_(rv.status_code, 201) eq_(data['result'], item) @@ -737,13 +984,20 @@ def test_create_item_val_size(self): REST Api: Test create validate size """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) field_string = 'a' * 51 item = dict( field_string=field_string, field_integer=11, field_float=11.0 ) - rv = client.post('api/v1/model1api/', json=item) + uri = 'api/v1/model1api/' + rv = self.auth_client_post( + client, + token, + uri, + item + ) eq_(rv.status_code, 400) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Longer than maximum length 50.') @@ -753,12 +1007,19 @@ def test_create_item_val_type(self): REST Api: Test create validate type """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) item = dict( field_string="test11", field_integer="test11", field_float=11.0 ) - rv = client.post('api/v1/model1api/', json=item) + uri = 'api/v1/model1api/' + rv = self.auth_client_post( + client, + token, + uri, + item + ) eq_(rv.status_code, 400) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_integer'][0], 'Not a valid integer.') @@ -768,7 +1029,12 @@ def test_create_item_val_type(self): field_integer=11, field_float=11.0 ) - rv = client.post('api/v1/model1api/', json=item) + rv = self.auth_client_post( + client, + token, + uri, + item + ) eq_(rv.status_code, 400) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Not a valid string.') @@ -778,16 +1044,28 @@ def test_create_item_excluded_cols(self): REST Api: Test create with excluded columns """ client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) item = dict( field_string="test{}".format(MODEL1_DATA_SIZE+1) ) - rv = client.post('api/v1/model1apiexcludecols/', json=item) + uri = 'api/v1/model1apiexcludecols/' + rv = self.auth_client_post( + client, + token, + uri, + item + ) eq_(rv.status_code, 201) item = dict( field_string="test{}".format(MODEL1_DATA_SIZE+2), field_integer=MODEL1_DATA_SIZE+2 ) - rv = client.post('api/v1/model1apiexcludecols/', json=item) + rv = self.auth_client_post( + client, + token, + uri, + item + ) eq_(rv.status_code, 201) model = (self.db.session.query(Model1) .filter_by(field_string="test11") @@ -801,7 +1079,13 @@ def test_get_list_col_function(self): REST Api: Test get list of objects with columns as functions """ client = self.app.test_client() - rv = client.get('api/v1/model1funcapi/') + token = self.login(client, USERNAME, PASSWORD) + uri = 'api/v1/model1funcapi/' + rv = self.auth_client_get( + client, + token, + uri + ) data = json.loads(rv.data.decode('utf-8')) # Tests count property eq_(data['count'], MODEL1_DATA_SIZE) diff --git a/flask_appbuilder/tests/test_base.py b/flask_appbuilder/tests/test_base.py index cf8148200a..ef182af198 100644 --- a/flask_appbuilder/tests/test_base.py +++ b/flask_appbuilder/tests/test_base.py @@ -205,7 +205,14 @@ class ModelWithEnumsView(ModelView): self.appbuilder.add_view(PSView, "Generic DS PS View", category='PSView') role_admin = self.appbuilder.sm.find_role('Admin') - self.appbuilder.sm.add_user('admin','admin','user','admin@fab.org',role_admin,'general') + self.appbuilder.sm.add_user( + 'admin', + 'admin', + 'user', + 'admin@fab.org', + role_admin, + 'general' + ) def tearDown(self): self.appbuilder = None diff --git a/rtd_requirements.txt b/rtd_requirements.txt index d4e192771a..90eb4b8263 100644 --- a/rtd_requirements.txt +++ b/rtd_requirements.txt @@ -9,5 +9,6 @@ Flask-OpenID>=1.2.5,<2 Flask-SQLAlchemy>=2.3,<3 Flask-WTF>=0.14.2,<1 flask-marshmallow==0.9.0 +Flask-JWT-Extended>=3.18,<4 marshmallow==2.18.0 marshmallow-sqlalchemy==0.15.0 diff --git a/setup.py b/setup.py index dc0da447f8..7e93a3213e 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ def desc(): 'Flask-OpenID>=1.2.5,<2', 'Flask-SQLAlchemy>=2.3,<3', 'Flask-WTF>=0.14.2,<1', + 'Flask-JWT-Extended>=3.18,<4', 'python-dateutil>=2.3,<3', 'flask-marshmallow==0.9.0', 'marshmallow==2.18.0', From e92f4bae3bb927e32b1d96afef751a589aaba472 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 18 Mar 2019 12:23:19 +0000 Subject: [PATCH 027/109] [api] New, security test and permissions info --- flask_appbuilder/api.py | 21 ++-- flask_appbuilder/security/api.py | 126 ++++++++++++++++++++++ flask_appbuilder/security/manager.py | 32 +++++- flask_appbuilder/security/sqla/manager.py | 1 + flask_appbuilder/security/views.py | 3 +- flask_appbuilder/tests/test_api.py | 75 ++++++++++--- flask_appbuilder/tests/test_base.py | 18 ++-- 7 files changed, 243 insertions(+), 33 deletions(-) create mode 100644 flask_appbuilder/security/api.py diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 1299c99f48..b0f8c95615 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -613,7 +613,7 @@ def _get_field_info(self, field, filter_rel_field): # Handles related fields if isinstance(field, Related) or isinstance(field, RelatedList): _rel_interface = self.datamodel.get_related_interface(field.name) - _filters = _rel_interface .get_filters(_rel_interface .get_search_columns_list()) + _filters = _rel_interface.get_filters(_rel_interface.get_search_columns_list()) if filter_rel_field: filters = _filters.add_filter_list(filter_rel_field) _values = _rel_interface.query(filters)[1] @@ -653,18 +653,24 @@ def _get_fields_info(self, cols, model_schema, filter_rel_fields): for col in cols ] - @expose('/info', methods=['GET']) + def _get_current_user_permissions(self): + return self.appbuilder.sm.get_user_permissions_on_view(self.__class__.__name__) + + @expose('/_info', methods=['GET']) @jwt_has_access @permission_name('get') def info(self): + _response = dict() + _response['permissions'] = \ + self._get_current_user_permissions() # Get info from add fields - _add_fields = self._get_fields_info( + _response['add_fields'] = self._get_fields_info( self.add_columns, self.add_model_schema, self.add_query_rel_fields ) # Get info from edit fields - _edit_fields = self._get_fields_info( + _response['_edit_fields'] = self._get_fields_info( self.edit_columns, self.edit_model_schema, self.edit_query_rel_fields @@ -678,15 +684,14 @@ def info(self): {'name': as_unicode(flt.name), 'operator': flt.arg_name} for flt in dict_filters[col] ] + _response['filters'] = search_filters return self.response( 200, - filters=search_filters, - add_fields=_add_fields, - edit_fields=_edit_fields + **_response ) @expose('/', methods=['GET']) - @expose('//', methods=['GET']) + @expose('/', methods=['GET']) @jwt_has_access @permission_name('get') def get(self, pk=None): diff --git a/flask_appbuilder/security/api.py b/flask_appbuilder/security/api.py new file mode 100644 index 0000000000..ecce33554c --- /dev/null +++ b/flask_appbuilder/security/api.py @@ -0,0 +1,126 @@ +from flask import request +from flask_jwt_extended import create_access_token +from flask_babel import lazy_gettext +from ..views import expose +from ..api import BaseApi, ModelApi + + +class SecurityApi(BaseApi): + + route_base = '/api/v1/security' + + @expose('/login', methods=['POST']) + def login(self): + """ + Login endpoint for the API returns a JWT + :return: + """ + if not request.is_json: + return self.response_400(message="Request payload is not JSON") + username = request.json.get('username', None) + password = request.json.get('password', None) + provider = request.json.get('provider', None) + if not username or not password or not provider: + return self.response_400(message="Missing required parameter") + # AUTH + if provider == 'db': + user = self.appbuilder.sm.auth_user_db( + username, + password + ) + elif provider == 'ldap': + user = self.appbuilder.sm.auth_user_ldap( + username, + password + ) + else: + return self.response_400( + message="Provider {} not supported".format(provider) + ) + if not user: + return self.response_401() + + # Identity can be any data that is json serializable + access_token = create_access_token(identity=user.id) + return self.response(200, access_token=access_token) + + +class UserApi(ModelApi): + route_base = '/api/v1/user' + + label_columns = { + 'get_full_name': lazy_gettext('Full Name'), + 'first_name': lazy_gettext('First Name'), + 'last_name': lazy_gettext('Last Name'), + 'username': lazy_gettext('User Name'), + 'password': lazy_gettext('Password'), + 'active': lazy_gettext('Is Active?'), + 'email': lazy_gettext('Email'), + 'roles': lazy_gettext('Role'), + 'last_login': lazy_gettext('Last login'), + 'login_count': lazy_gettext('Login count'), + 'fail_login_count': lazy_gettext('Failed login count'), + 'created_on': lazy_gettext('Created on'), + 'created_by': lazy_gettext('Created by'), + 'changed_on': lazy_gettext('Changed on'), + 'changed_by': lazy_gettext('Changed by') + } + + description_columns = { + 'first_name': lazy_gettext( + 'Write the user first name or names' + ), + 'last_name': lazy_gettext( + 'Write the user last name' + ), + 'username': lazy_gettext( + 'Username valid for authentication on DB or LDAP, unused for OID auth' + ), + 'password': lazy_gettext( + 'Please use a good password policy, this application does not check this for you' + ), + 'active': lazy_gettext( + 'It\'s not a good policy to remove a user, just make it inactive' + ), + 'email': lazy_gettext( + 'The user\'s email, this will also be used for OID auth' + ), + 'roles': lazy_gettext( + 'The user role on the application, this will associate with a list of permissions' + ), + 'conf_password': lazy_gettext( + 'Please rewrite the user\'s password to confirm' + ) + } + + list_columns = [ + 'first_name', + 'last_name', + 'username', + 'email', + 'active', + 'roles' + ] + show_columns = [ + 'first_name', + 'last_name', + 'username', + 'active', + 'email', + 'roles' + ] + add_columns = [ + 'first_name', + 'last_name', + 'username', + 'active', + 'email', + 'roles'] + edit_columns = [ + 'first_name', + 'last_name', + 'username', + 'active', + 'email', + 'roles' + ] diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index 1540f5868a..bc00637ad2 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -8,8 +8,11 @@ from flask_login import LoginManager, current_user from flask_openid import OpenID from flask_babel import lazy_gettext as _ -from .views import ( +from .api import ( SecurityApi, + UserApi +) +from .views import ( AuthDBView, AuthOIDView, ResetMyPasswordView, @@ -183,6 +186,12 @@ class BaseSecurityManager(AbstractSecurityManager): userinfoeditview = UserInfoEditView """ Override if you want your own User information edit view """ + # API + security_api = SecurityApi + """ Override if you want your own Security API login endpoint """ + user_api = UserApi + """ Override if you want your own user API """ + rolemodelview = RoleModelView permissionmodelview = PermissionModelView userstatschartview = UserStatsChartView @@ -519,7 +528,9 @@ def _azure_jwt_token_parse(self, id_token): def register_views(self): - self.appbuilder.add_view_no_menu(SecurityApi) + # Security APIs + self.appbuilder.add_view_no_menu(self.security_api) + self.appbuilder.add_view_no_menu(self.user_api) if self.auth_user_registration: if self.auth_type == AUTH_DB: @@ -983,6 +994,23 @@ def has_access(self, permission_name, view_name): else: return self.is_item_public(permission_name, view_name) + @staticmethod + def get_user_permissions_on_view(view_name): + """ + Returns all current user permissions + on a certain view/resource + :param view_name: The name of the view/resource/menu + :return: (list) with permissions + """ + _ret = list() + if current_user.is_authenticated: + for role in current_user.roles: + if role.permissions: + for permission in role.permissions: + if permission.view_menu.name == view_name: + _ret.append(permission.permission.name) + return _ret + def add_permissions_view(self, base_permissions, view_menu): """ Adds a permission on a view menu to the backend diff --git a/flask_appbuilder/security/sqla/manager.py b/flask_appbuilder/security/sqla/manager.py index c4864baf71..12b02ca3dc 100644 --- a/flask_appbuilder/security/sqla/manager.py +++ b/flask_appbuilder/security/sqla/manager.py @@ -57,6 +57,7 @@ def __init__(self, appbuilder): self.permissionmodelview.datamodel = SQLAInterface(self.permission_model) self.viewmenumodelview.datamodel = SQLAInterface(self.viewmenu_model) self.permissionviewmodelview.datamodel = SQLAInterface(self.permissionview_model) + self.user_api.datamodel = SQLAInterface(self.user_model) self.create_db() @property diff --git a/flask_appbuilder/security/views.py b/flask_appbuilder/security/views.py index c834443fcc..adca422039 100644 --- a/flask_appbuilder/security/views.py +++ b/flask_appbuilder/security/views.py @@ -44,7 +44,8 @@ def login(self): # https://tools.ietf.org/html/rfc7519 # https://auth0.com/blog/json-web-token-signing-algorithms-overview/ - print("LOGIN PAYLOAD {}".format(request.json)) + if not request.is_json: + return self.response_400(message="Request payload is not JSON") username = request.json.get('username', None) password = request.json.get('password', None) provider = request.json.get('provider', None) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 341b29291e..23a3b5cb9c 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -184,9 +184,15 @@ def auth_client_post(client, token, uri, json): ) @staticmethod - def login(client, username, password): - # Login with default admin - rv = client.post( + def _login(client, username, password): + """ + Login help method + :param client: Flask test client + :param username: username + :param password: password + :return: Flask client response class + """ + return client.post( 'api/v1/security/login', data=json.dumps(dict( username=username, @@ -195,8 +201,45 @@ def login(client, username, password): )), content_type='application/json' ) - log.fatal("FATAL {}".format(rv.data)) - return json.loads(rv.data.decode('utf-8')).get("access_token") + + def login(self, client, username, password): + # Login with default admin + rv = self._login(client, username, password) + try: + return json.loads(rv.data.decode('utf-8')).get("access_token") + except: + return rv + + def test_auth_login(self): + """ + REST Api: Test auth login + """ + client = self.app.test_client() + rv = self._login(client, USERNAME, PASSWORD) + eq_(rv.status_code, 200) + assert json.loads( + rv.data.decode('utf-8') + ).get("access_token", False) + + def test_auth_login_failed(self): + """ + REST Api: Test auth login failed + """ + client = self.app.test_client() + rv = self._login(client, "fail", "fail") + eq_(json.loads(rv.data), {"message": "Not authorized"}) + eq_(rv.status_code, 401) + + def test_auth_login_bad(self): + """ + REST Api: Test auth login bad request + """ + client = self.app.test_client() + rv = client.post( + 'api/v1/security/login', + data="BADADATA" + ) + eq_(rv.status_code, 400) def test_get_item(self): """ @@ -208,7 +251,7 @@ def test_get_item(self): rv = self.auth_client_get( client, token, - 'api/v1/model1api/{}/'.format(i) + 'api/v1/model1api/{}'.format(i) ) data = json.loads(rv.data.decode('utf-8')) eq_(rv.status_code, 200) @@ -243,7 +286,7 @@ def test_get_item_select_cols(self): rv = self.auth_client_get( client, token, - 'api/v1/model1api/{}/?_c_=field_integer'.format(i) + 'api/v1/model1api/{}?_c_=field_integer'.format(i) ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'], {'field_integer': i - 1}) @@ -266,7 +309,7 @@ def test_get_item_excluded_cols(self): rv = self.auth_client_get( client, token, - 'api/v1/model1apiexcludecols/{}/'.format(pk) + 'api/v1/model1apiexcludecols/{}'.format(pk) ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'], { @@ -285,7 +328,7 @@ def test_get_item_not_found(self): rv = self.auth_client_get( client, token, - 'api/v1/model1api/{}/'.format(pk) + 'api/v1/model1api/{}'.format(pk) ) eq_(rv.status_code, 404) @@ -301,7 +344,7 @@ def test_get_item_base_filters(self): rv = self.auth_client_get( client, token, - 'api/v1/model1apifiltered/{}/'.format(pk) + 'api/v1/model1apifiltered/{}'.format(pk) ) eq_(rv.status_code, 404) # This one is ok pk=4 field_integer=3 2>3<4 @@ -309,7 +352,7 @@ def test_get_item_base_filters(self): rv = self.auth_client_get( client, token, - 'api/v1/model1apifiltered/{}/'.format(pk) + 'api/v1/model1apifiltered/{}'.format(pk) ) eq_(rv.status_code, 200) @@ -325,7 +368,7 @@ def test_get_item_rel_field(self): rv = self.auth_client_get( client, token, - 'api/v1/model2api/{}/'.format(pk) + 'api/v1/model2api/{}'.format(pk) ) data = json.loads(rv.data.decode('utf-8')) eq_(rv.status_code, 200) @@ -577,7 +620,7 @@ def test_info_filters(self): """ client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - uri = 'api/v1/model1api/info' + uri = 'api/v1/model1api/_info' rv = self.auth_client_get( client, token, @@ -623,7 +666,7 @@ def test_info_fields(self): client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - uri = 'api/v1/model1apifieldsinfo/info' + uri = 'api/v1/model1apifieldsinfo/_info' rv = self.auth_client_get( client, token, @@ -675,7 +718,7 @@ def test_info_fields_rel_field(self): client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - uri = 'api/v1/model2api/info' + uri = 'api/v1/model2api/_info' rv = self.auth_client_get( client, token, @@ -708,7 +751,7 @@ def test_info_fields_rel_filtered_field(self): """ client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - uri = 'api/v1/model2apifilteredrelfields/info' + uri = 'api/v1/model2apifilteredrelfields/_info' rv = self.auth_client_get( client, token, diff --git a/flask_appbuilder/tests/test_base.py b/flask_appbuilder/tests/test_base.py index ef182af198..b8eb567aef 100644 --- a/flask_appbuilder/tests/test_base.py +++ b/flask_appbuilder/tests/test_base.py @@ -376,15 +376,22 @@ def test_sec_reset_password(self): eq_(rv.status_code, 200) #Reset Password Admin - rv = client.get('/users/action/resetpasswords/1', follow_redirects=True) + rv = client.get( + '/users/action/resetpasswords/1', + follow_redirects=True + ) data = rv.data.decode('utf-8') ok_("Reset Password Form" in data) - rv = client.post('/resetmypassword/form', - data=dict(password=DEFAULT_ADMIN_PASSWORD, conf_password=DEFAULT_ADMIN_PASSWORD), - follow_redirects=True) + rv = client.post( + '/resetmypassword/form', + data=dict( + password=DEFAULT_ADMIN_PASSWORD, + conf_password=DEFAULT_ADMIN_PASSWORD + ), + follow_redirects=True + ) eq_(rv.status_code, 200) - def test_generic_interface(self): """ Test Generic Interface for generic-alter datasource @@ -394,7 +401,6 @@ def test_generic_interface(self): rv = client.get('/psview/list') data = rv.data.decode('utf-8') - def test_model_crud(self): """ Test Model add, delete, edit From fe219192d85682f29f7e59173dc5083b20950e76 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 18 Mar 2019 19:00:30 +0000 Subject: [PATCH 028/109] [api] New, use rison and selectable meta keys --- flask_appbuilder/api.py | 262 +++++++++++++++++++++-------- flask_appbuilder/models/filters.py | 4 +- flask_appbuilder/tests/test_api.py | 27 ++- rtd_requirements.txt | 1 + setup.py | 3 +- 5 files changed, 219 insertions(+), 78 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index b0f8c95615..b60d0260a4 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -1,6 +1,7 @@ import re import logging import functools +import prison from flask import Blueprint, make_response, jsonify, request from flask_babel import lazy_gettext as _ from .security.decorators import permission_name, jwt_has_access @@ -13,6 +14,22 @@ URI_PAGE_PREFIX = "_p_" URI_FILTER_PREFIX = "_f_" URI_SELECT_COL_PREFIX = "_c_" +URI_RISON_KEY = 'q' + + +def rison(f): + def wraps(self, *args, **kwargs): + + value = request.args.get(URI_RISON_KEY, None) + kwargs['rison'] = dict() + if value: + try: + kwargs['rison'] = \ + prison.loads(value) + except prison.decoder.ParserException: + return self.response_400(message="Not valid rison argument") + return f(self, *args, **kwargs) + return functools.update_wrapper(wraps, f) def order_args(f): @@ -145,6 +162,29 @@ def wrap(f): return wrap +def merge_response_func(func, key): + """ + Use this decorator to set a new merging + response function to HTTP endpoints + + candidate function must have the following signature + and be childs of BaseApi: + ``` + def merge_some_function(self, response, rison_args): + ``` + + :param func: Name of the merge function where the key is allower + :param key: The key name for rison selection + :return: None + """ + def wrap(f): + if not hasattr(f, '_response_key_func_mappings'): + f._response_key_func_mappings = dict() + f._response_key_func_mappings[key] = func + return f + return wrap + + class BaseApi: """ All apis inherit from this class. @@ -172,6 +212,7 @@ def __init__(self): Initialization of extra args """ + self._response_key_func_mappings = dict() if self.base_permissions is None: self.base_permissions = set() for attr_name in dir(self): @@ -253,6 +294,24 @@ def get_init_inner_views(self, views): """ pass + def set_response_key_mappings(self, response, func, rison_args, **kwargs): + if not hasattr(func, '_response_key_func_mappings'): + return + _keys = rison_args.get('keys', None) + if not _keys: + for k, v in func._response_key_func_mappings.items(): + v(self, response, **kwargs) + else: + for k, v in func._response_key_func_mappings.items(): + if k in _keys: + v(self, response, **kwargs) + + def merge_current_user_permissions(self, response, **kwargs): + response['permissions'] =\ + self.appbuilder.sm.get_user_permissions_on_view( + self.__class__.__name__ + ) + @staticmethod def response(code, **kwargs): _ret_json = jsonify(kwargs) @@ -597,6 +656,33 @@ def _init_properties(self): self.edit_query_rel_fields = self.edit_query_rel_fields or dict() self.add_query_rel_fields = self.add_query_rel_fields or dict() + def merge_add_field_info(self, response, **kwargs): + response['add_fields'] = \ + self._get_fields_info( + self.add_columns, + self.add_model_schema, + self.add_query_rel_fields + ) + + def merge_edit_field_info(self, response, **kwargs): + response['edit_fields'] = \ + self._get_fields_info( + self.edit_columns, + self.edit_model_schema, + self.edit_query_rel_fields + ) + + def merge_search_filters(self, response, **kwargs): + # Get possible search fields and all possible operations + search_filters = dict() + dict_filters = self._filters.get_search_filters() + for col in self.search_columns: + search_filters[col] = [ + {'name': as_unicode(flt.name), + 'operator': flt.arg_name} for flt in dict_filters[col] + ] + response['filters'] = search_filters + def _get_field_info(self, field, filter_rel_field): """ Return a dict with field details @@ -653,38 +739,35 @@ def _get_fields_info(self, cols, model_schema, filter_rel_fields): for col in cols ] - def _get_current_user_permissions(self): - return self.appbuilder.sm.get_user_permissions_on_view(self.__class__.__name__) - @expose('/_info', methods=['GET']) @jwt_has_access @permission_name('get') - def info(self): + @rison + @merge_response_func( + BaseApi.merge_current_user_permissions, + 'permissions' + ) + @merge_response_func( + merge_add_field_info, + 'add_fields' + ) + @merge_response_func( + merge_edit_field_info, + 'edit_fields' + ) + @merge_response_func( + merge_search_filters, + "filters" + ) + def info(self, **kwargs): _response = dict() - _response['permissions'] = \ - self._get_current_user_permissions() - # Get info from add fields - _response['add_fields'] = self._get_fields_info( - self.add_columns, - self.add_model_schema, - self.add_query_rel_fields + _args = kwargs.get('rison', {}) + self.set_response_key_mappings( + _response, + self.info, + _args, + **{} ) - # Get info from edit fields - _response['_edit_fields'] = self._get_fields_info( - self.edit_columns, - self.edit_model_schema, - self.edit_query_rel_fields - ) - - # Get possible search fields and all possible operations - search_filters = dict() - dict_filters = self._filters.get_search_filters() - for col in self.search_columns: - search_filters[col] = [ - {'name': as_unicode(flt.name), - 'operator': flt.arg_name} for flt in dict_filters[col] - ] - _response['filters'] = search_filters return self.response( 200, **_response @@ -757,73 +840,120 @@ def delete(self, pk): else: return self.response_500() - @select_col_args - def _get_item(self, pk, select_cols=None): + def merge_label_columns(self, response, **kwargs): + _pruned_select_cols = kwargs.get('columns', []) + if _pruned_select_cols: + _show_columns = _pruned_select_cols + else: + _show_columns = self.show_columns + response['label_columns'] = self._label_columns_json(_show_columns) + + def merge_include_columns(self, response, **kwargs): + _pruned_select_cols = kwargs.get('columns', []) + if _pruned_select_cols: + response['include_columns'] = _pruned_select_cols + else: + response['include_columns'] = self.show_columns + + def merge_description_columns(self, response, **kwargs): + _pruned_select_cols = kwargs.get('columns', []) + if _pruned_select_cols: + response['description_columns'] = \ + self._description_columns_json(_pruned_select_cols) + else: + response['description_columns'] = \ + self._description_columns_json(self.show_columns) + + def merge_list_columns(self, response, **kwargs): + _pruned_select_cols = kwargs.get('columns', []) + if _pruned_select_cols: + response['list_columns'] = _pruned_select_cols + else: + response['list_columns'] = self.list_columns + + def merge_order_columns(self, response, **kwargs): + _pruned_select_cols = kwargs.get('columns', []) + response['order_columns'] = [ + order_col + for order_col in self.order_columns if order_col in _pruned_select_cols + ] + + @rison + @merge_response_func(merge_label_columns, "label_columns") + @merge_response_func(merge_include_columns, "include_columns") + @merge_response_func(merge_description_columns, "description_columns") + def _get_item(self, pk, **kwargs): item = self.datamodel.get(pk, self._base_filters) if not item: return self.response_404() - select_cols = select_cols or [] - _pruned_select_cols = [col for col in select_cols if col in self.show_columns] + + _response = dict() + _args = kwargs.get('rison', {}) + select_cols = _args.get('columns', []) + _pruned_select_cols = [ + col for col in select_cols if col in self.show_columns + ] + self.set_response_key_mappings( + _response, + self._get_item, + _args, + **{"columns": _pruned_select_cols} + ) if _pruned_select_cols: - _show_columns = _pruned_select_cols _show_model_schema = self._model_schema_factory(_pruned_select_cols) else: - _show_columns = self.show_columns _show_model_schema = self.show_model_schema - return self.response( - 200, pk=pk, - label_columns=self._label_columns_json(_show_columns), - include_columns=_show_columns, - description_columns=self._description_columns_json(_show_columns), - modelview_name=self.__class__.__name__, - result=_show_model_schema.dump(item, many=False).data - ) - @order_args - @page_args - @filter_args - @select_col_args - def _get_list(self, filters=None, order_column=None, order_direction=None, - page_size=0, page_index=0, select_cols=None): + _response['pk'] = pk + _response['result'] = _show_model_schema.dump(item, many=False).data + return self.response(200, **_response) + @rison + @merge_response_func(merge_order_columns, "order_columns") + @merge_response_func(merge_label_columns, "label_columns") + @merge_response_func(merge_description_columns, "description_columns") + @merge_response_func(merge_list_columns, 'list_columns') + def _get_list(self, **kwargs): + _response = dict() + _args = kwargs.get('rison', {}) # handle select columns - select_cols = select_cols or [] + + select_cols = _args.get('columns', []) _pruned_select_cols = [col for col in select_cols if col in self.list_columns] + self.set_response_key_mappings( + _response, + self._get_list, + _args, + **{"columns": _pruned_select_cols} + ) + if _pruned_select_cols: - _list_columns = _pruned_select_cols _list_model_schema = self._model_schema_factory(_pruned_select_cols) else: - _list_columns = self.list_columns _list_model_schema = self.list_model_schema # handle filters self._filters.clear_filters() - self._filters.rest_add_filters(filters) + self._filters.rest_add_filters(_args.get('filters', [])) joined_filters = self._filters.get_joined_filters(self._base_filters) # handle base order + order_column = _args.get('order_column', '') + order_direction = _args.get('order_direction', '') if not order_column and self.base_order: order_column, order_direction = self.base_order # Make the query + page_index = _args.get('page', 0) + page_size = _args.get('page_size', self.page_size) count, lst = self.datamodel.query(joined_filters, order_column, order_direction, page=page_index, page_size=page_size) pks = self.datamodel.get_keys(lst) - order_columns = [ - order_col - for order_col in self.order_columns if order_col in _list_columns - ] - return self.response( - 200, - label_columns=self._label_columns_json(_list_columns), - list_columns=_list_columns, - description_columns=self._description_columns_json(_list_columns), - order_columns=order_columns, - modelview_name=self.__class__.__name__, - count=count, - ids=pks, - result=_list_model_schema.dump(lst, many=True).data - ) + _response['result'] = _list_model_schema.dump(lst, many=True).data + _response['ids'] = pks + _response['count'] = count + return self.response(200, **_response) + """ ------------------------------------------------ HELPER FUNCTIONS diff --git a/flask_appbuilder/models/filters.py b/flask_appbuilder/models/filters.py index b5d2486d3c..2186882618 100644 --- a/flask_appbuilder/models/filters.py +++ b/flask_appbuilder/models/filters.py @@ -159,10 +159,10 @@ def rest_add_filters(self, data): :return: """ for _filter in data: - filter_class = map_args_filter.get(_filter['operator'], None) + filter_class = map_args_filter.get(_filter['opr'], None) print("OPR {}".format(filter_class)) if filter_class: - self.add_filter(_filter['col_name'], filter_class, + self.add_filter(_filter['col'], filter_class, _filter['value']) def add_filter(self, column_name, filter_class, value): diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 23a3b5cb9c..e7883b8acd 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -283,10 +283,11 @@ def test_get_item_select_cols(self): token = self.login(client, USERNAME, PASSWORD) for i in range(1, MODEL1_DATA_SIZE): + uri = 'api/v1/model1api/{}?q=(columns:!(field_integer))'.format(i) rv = self.auth_client_get( client, token, - 'api/v1/model1api/{}?_c_=field_integer'.format(i) + uri ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'], {'field_integer': i - 1}) @@ -386,6 +387,8 @@ def test_get_list(self): token, 'api/v1/model1api/' ) + log.info("DATA !!!!!!!!! {}".format(rv.data)) + data = json.loads(rv.data.decode('utf-8')) # Tests count property eq_(data['count'], MODEL1_DATA_SIZE) @@ -412,10 +415,11 @@ def test_get_list_order(self): token = self.login(client, USERNAME, PASSWORD) # test string order asc + uri = 'api/v1/model1api/?q=(order_column:field_string,order_direction:asc)' rv = self.auth_client_get( client, token, - 'api/v1/model1api/?_o_=field_string:asc' + uri ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { @@ -426,10 +430,11 @@ def test_get_list_order(self): }) eq_(rv.status_code, 200) # test string order desc + uri = 'api/v1/model1api/?q=(order_column:field_string,order_direction:desc)' rv = self.auth_client_get( client, token, - 'api/v1/model1api/?_o_=field_string:desc' + uri ) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { @@ -462,10 +467,11 @@ def test_get_list_base_order(self): 'field_string': "test{}".format(MODEL1_DATA_SIZE - 1) }) # Test override + uri = 'api/v1/model1apiorder/?q_=(order_column:field_integer,order_direction:asc)' rv = self.auth_client_get( client, token, - 'api/v1/model1apiorder/?_o_=field_integer:asc' + 'api/v1/model1apiorder/?q=(order_column:field_integer,order_direction:asc)' ) data = json.loads(rv.data.decode('utf-8')) @@ -486,7 +492,7 @@ def test_get_list_page(self): token = self.login(client, USERNAME, PASSWORD) # test page zero - uri = 'api/v1/model1api/?_p_={}:0&_o_=field_integer:asc'.format(page_size) + uri = 'api/v1/model1api/?q=(page_size:{},page:0,order_column:field_integer,order_direction:asc)'.format(page_size) rv = self.auth_client_get( client, token, @@ -502,12 +508,14 @@ def test_get_list_page(self): eq_(rv.status_code, 200) eq_(len(data['result']), page_size) # test page zero - uri = 'api/v1/model1api/?_p_={}:1&_o_=field_integer:asc'.format(page_size) + uri = 'api/v1/model1api/?q=(page_size:{},page:1,order_column:field_integer,order_direction:asc)'.format(page_size) rv = self.auth_client_get( client, token, uri ) + log.info("DATA !!!!! {}".format(rv.data)) + data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { 'field_date': None, @@ -527,7 +535,8 @@ def test_get_list_filters(self): filter_value = 5 # test string order asc - uri = 'api/v1/model1api/?_f_0=field_integer:gt:{}&_o_=field_integer:asc'.format(filter_value) + uri = 'api/v1/model1api/?q=(filters:!((col:field_integer,opr:gt,value:{})),order_columns:field_integer,order_direction:asc)'.format(filter_value) + rv = self.auth_client_get( client, token, @@ -549,7 +558,7 @@ def test_get_list_select_cols(self): client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - uri = 'api/v1/model1api/?_c_=field_integer&_o_=field_integer:asc' + uri = 'api/v1/model1api/?q=(columns:!(field_integer),order_column:field_integer,order_direction:asc)' rv = self.auth_client_get( client, token, @@ -596,7 +605,7 @@ def test_get_list_base_filters(self): client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - uri = 'api/v1/model1apifiltered/?_o_=field_integer:asc' + uri = 'api/v1/model1apifiltered/?order_columns:field_integer,order_direction:asc' rv = self.auth_client_get( client, token, diff --git a/rtd_requirements.txt b/rtd_requirements.txt index 90eb4b8263..adc2d2b3c2 100644 --- a/rtd_requirements.txt +++ b/rtd_requirements.txt @@ -12,3 +12,4 @@ flask-marshmallow==0.9.0 Flask-JWT-Extended>=3.18,<4 marshmallow==2.18.0 marshmallow-sqlalchemy==0.15.0 +prison==0.1.0 diff --git a/setup.py b/setup.py index 7e93a3213e..aab03d3932 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,8 @@ def desc(): 'python-dateutil>=2.3,<3', 'flask-marshmallow==0.9.0', 'marshmallow==2.18.0', - 'marshmallow-sqlalchemy==0.15.0' + 'marshmallow-sqlalchemy==0.15.0', + 'prison==0.1.0' ], tests_require=[ 'nose>=1.0', From 67215c4519f798618a095f634805e7b9b6a0c4ed Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 18 Mar 2019 19:32:24 +0000 Subject: [PATCH 029/109] [tests] Fix, wrong number of initial appbuilder views --- .gitignore | 2 +- flask_appbuilder/tests/test_base.py | 2 +- flask_appbuilder/tests/test_mongoengine.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 3980740cfa..fec4882447 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,4 @@ env venv *.sublime* .vscode/ - +.tox diff --git a/flask_appbuilder/tests/test_base.py b/flask_appbuilder/tests/test_base.py index b8eb567aef..eafa8eb03e 100644 --- a/flask_appbuilder/tests/test_base.py +++ b/flask_appbuilder/tests/test_base.py @@ -279,7 +279,7 @@ def test_fab_views(self): """ Test views creation and registration """ - eq_(len(self.appbuilder.baseviews), 32) # current minimal views are 12 + eq_(len(self.appbuilder.baseviews), 34) # current minimal views are 34 def test_back(self): """ diff --git a/flask_appbuilder/tests/test_mongoengine.py b/flask_appbuilder/tests/test_mongoengine.py index 38c6a9fad2..c309f24607 100644 --- a/flask_appbuilder/tests/test_mongoengine.py +++ b/flask_appbuilder/tests/test_mongoengine.py @@ -234,7 +234,7 @@ def test_fab_views(self): """ Test views creation and registration """ - eq_(len(self.appbuilder.baseviews), 24) # current minimal views are 12 + eq_(len(self.appbuilder.baseviews), 26) # current minimal views are 26 def test_index(self): """ From e7d1ca4a488611571f72d3d96b93fa60ddc22b6c Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 19 Mar 2019 23:38:01 +0000 Subject: [PATCH 030/109] [api] Fix, POST and PUT on custom validators --- flask_appbuilder/api.py | 211 ++++++++---------------- flask_appbuilder/security/api.py | 6 +- flask_appbuilder/security/decorators.py | 21 ++- flask_appbuilder/security/views.py | 15 +- flask_appbuilder/tests/test_api.py | 16 +- flask_appbuilder/tests/test_base.py | 6 +- 6 files changed, 101 insertions(+), 174 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index b60d0260a4..6c484be1c6 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -1,145 +1,57 @@ import re import logging import functools +import traceback import prison -from flask import Blueprint, make_response, jsonify, request +from flask import Blueprint, make_response, jsonify, request, current_app +from werkzeug.exceptions import BadRequest from flask_babel import lazy_gettext as _ -from .security.decorators import permission_name, jwt_has_access +from .security.decorators import permission_name, protect from marshmallow import ValidationError from ._compat import as_unicode log = logging.getLogger(__name__) -URI_ORDER_BY_PREFIX = "_o_" -URI_PAGE_PREFIX = "_p_" -URI_FILTER_PREFIX = "_f_" -URI_SELECT_COL_PREFIX = "_c_" URI_RISON_KEY = 'q' -def rison(f): - def wraps(self, *args, **kwargs): - - value = request.args.get(URI_RISON_KEY, None) - kwargs['rison'] = dict() - if value: - try: - kwargs['rison'] = \ - prison.loads(value) - except prison.decoder.ParserException: - return self.response_400(message="Not valid rison argument") - return f(self, *args, **kwargs) - return functools.update_wrapper(wraps, f) - - -def order_args(f): +def get_error_msg(): """ - Get order arguments decorator - - Arguments are passed like: _o_=:'' - - function is called with named args: order_column, order_direction + (inspired on Superset code) + :return: """ - def wraps(self, *args, **kwargs): - orders = {} - for arg, value in request.args.items(): - if arg == URI_ORDER_BY_PREFIX: - re_match = re.findall('(.*):(.*)', value) - for _item_re_match in re_match: - if _item_re_match and _item_re_match[1] in ('asc', 'desc'): - orders[_item_re_match[0]] = _item_re_match[1] - if orders: - for order_col, order_dir in orders.items(): - order_column, order_direction = order_col, order_dir - else: - order_column, order_direction = '', '' - kwargs['order_column'] = order_column - kwargs['order_direction'] = order_direction - return f(self, *args, - order_column=order_column, - order_direction=order_direction) - return functools.update_wrapper(wraps, f) + if current_app.config.get("FAB_API_SHOW_STACKTRACE"): + return traceback.format_exc() + return "Fatal error" -def page_args(f): +def safe(f): """ - Get page arguments decorator - - Arguments are passed like: _p_=:'' - - function is called with named args: page_size, page_index + A decorator that catches uncaught exceptions and + return the response in JSON format (inspired on Superset code) """ def wraps(self, *args, **kwargs): - for arg, value in request.args.items(): - if arg == URI_PAGE_PREFIX: - re_match = re.findall('(.*):(.*)', value) - for _item_re_match in re_match: - if _item_re_match and len(_item_re_match) == 2: - try: - kwargs['page_size'] = int(_item_re_match[0]) - kwargs['page_index'] = int(_item_re_match[1]) - return f(self, *args, **kwargs) - except ValueError: - log.warning("Bad page args {}, {}".format( - _item_re_match[0], - _item_re_match[1]) - ) - kwargs['page_size'] = self.page_size - kwargs['page_index'] = 0 - return f(self, *args, **kwargs) + try: + return f(self, *args, **kwargs) + except BadRequest as e: + return self.response_400(message=str(e)) + except Exception as e: + logging.exception(e) + return self.response_500(message=get_error_msg()) return functools.update_wrapper(wraps, f) -def select_col_args(f): - """ - Get selectable columns on the fly - - Arguments are passed like: _c_=,, ... - - function is called with named args: page_size, page_index - """ +def rison(f): def wraps(self, *args, **kwargs): - for arg, value in request.args.items(): - if arg == URI_SELECT_COL_PREFIX: - re_match = re.findall('(\w+),*', value) - kwargs['select_cols'] = [i for i in re_match] - return f(self, *args, **kwargs) - return functools.update_wrapper(wraps, f) - - -def filter_args(f): - """ - Get filter arguments, return a list of dicts - { : (ORDER_COL, ORDER_DIRECTION) } - - Arguments are passed like: _f_=:: - :return: list [ - { - "col_name":"", - "operator":"", - "value":"", - } - ] - """ - def wraps(self, *args, **kwargs): - filters = list() - for arg, value in request.args.items(): - key_match = re.match(r"{}(\d)".format(URI_FILTER_PREFIX), arg) - if key_match: - re_match = re.findall('(.*):(.*):(.*)', value) - for _item_re_match in re_match: - if _item_re_match and len(_item_re_match) == 3: - filters.append( - { - "col_name": _item_re_match[0], - "operator": _item_re_match[1], - "value": _item_re_match[2], - } - ) - else: - log.warning("Bar filter args {} ".format(_item_re_match)) - kwargs['filters'] = filters + value = request.args.get(URI_RISON_KEY, None) + kwargs['rison'] = dict() + if value: + try: + kwargs['rison'] = \ + prison.loads(value) + except prison.decoder.ParserException: + return self.response_400(message="Not valid rison argument") return f(self, *args, **kwargs) return functools.update_wrapper(wraps, f) @@ -201,7 +113,14 @@ class BaseApi: version = 'v1' route_base = None - + """ + Define the route base where all methods will sufix from + """ + resource_name = None + """ + Defines a custom resource name, overrides the inferred from Class name + makes no sense to use it with route base + """ base_permissions = None extra_args = None @@ -236,11 +155,12 @@ def create_blueprint(self, appbuilder, self.appbuilder = appbuilder # If endpoint name is not provided, get it from the class name self.endpoint = endpoint or self.__class__.__name__ + self.resource_name = self.resource_name or self.__class__.__name__ if self.route_base is None: self.route_base = \ "/api/{}/{}".format(self.version, - self.__class__.__name__.lower()) + self.resource_name.lower()) self.blueprint = Blueprint(self.endpoint, __name__, url_prefix=self.route_base) @@ -348,7 +268,7 @@ class MyModelApi(BaseModelApi): columns will be used. If you want to limit the search (*filter*) columns possibilities, define it with a list of column names from your model:: - class MyView(ModelView): + class MyView(ModelRestApi): datamodel = SQLAInterface(MyTable) search_columns = ['name', 'address'] @@ -365,7 +285,7 @@ class MyView(ModelView): example (will just override the label for name column):: - class MyView(ModelApi): + class MyView(ModelRestApi): datamodel = SQLAInterface(MyTable) label_columns = {'name':'My Name Label Override'} @@ -379,7 +299,7 @@ class MyView(ModelApi): def get_user(): return g.user - class MyView(ModelApi): + class MyView(ModelRestApi): datamodel = SQLAInterface(MyTable) base_filters = [['created_by', FilterEqualFunction, get_user], ['name', FilterStartsWith, 'a']] @@ -391,7 +311,7 @@ class MyView(ModelApi): Use this property to set default ordering for lists ('col_name','asc|desc'):: - class MyView(ModelApi): + class MyView(ModelRestApi): datamodel = SQLAInterface(MyTable) base_order = ('my_column_name','asc') @@ -458,7 +378,7 @@ def _init_titles(self): pass -class ModelApi(BaseModelApi): +class ModelRestApi(BaseModelApi): list_title = "" """ List Title, if not configured the default is @@ -570,7 +490,7 @@ class ContactModelView(ModelView): def create_blueprint(self, appbuilder, *args, **kwargs): self._init_model_schemas() - return super(ModelApi, self).create_blueprint(appbuilder, + return super(ModelRestApi, self).create_blueprint(appbuilder, *args, **kwargs) def _init_model_schemas(self): @@ -605,7 +525,7 @@ def _init_titles(self): """ Init Titles if not defined """ - super(ModelApi, self)._init_titles() + super(ModelRestApi, self)._init_titles() class_name = self.datamodel.model_name if not self.list_title: self.list_title = 'List ' + self._prettify_name(class_name) @@ -621,7 +541,7 @@ def _init_properties(self): """ Init Properties """ - super(ModelApi, self)._init_properties() + super(ModelRestApi, self)._init_properties() # Reset init props self.description_columns = self.description_columns or {} self.formatters_columns = self.formatters_columns or {} @@ -714,8 +634,10 @@ def _get_field_info(self, field, filter_rel_field): } ) - if field.validate: + if field.validate and isinstance(field.validate, list): ret['validate'] = [str(v) for v in field.validate] + elif field.validate: + ret['validate'] = [str(field.validate)] ret['type'] = field.__class__.__name__ ret['required'] = field.required return ret @@ -740,9 +662,10 @@ def _get_fields_info(self, cols, model_schema, filter_rel_fields): ] @expose('/_info', methods=['GET']) - @jwt_has_access - @permission_name('get') + @protect @rison + @safe + @permission_name('get') @merge_response_func( BaseApi.merge_current_user_permissions, 'permissions' @@ -775,7 +698,8 @@ def info(self, **kwargs): @expose('/', methods=['GET']) @expose('/', methods=['GET']) - @jwt_has_access + @protect + @safe @permission_name('get') def get(self, pk=None): if not pk: @@ -783,7 +707,8 @@ def get(self, pk=None): return self._get_item(pk) @expose('/', methods=['POST']) - @jwt_has_access + @protect + @safe @permission_name('post') def post(self): try: @@ -791,18 +716,25 @@ def post(self): except ValidationError as err: return self.response(400, **{'message': err.messages}) else: + # This validates custom Schema with custom validations + if isinstance(item.data, dict): + return self.response(400, **{'message': item.errors}) self.pre_add(item.data) if self.datamodel.add(item.data): self.post_add(item.data) return self.response( 201, - **{'result': self.add_model_schema.dump(item.data, many=False).data} + **{ + 'result': self.add_model_schema.dump(item.data, many=False).data, + 'id': self.datamodel.get_pk_value(item.data) + } ) else: return self.response_500() @expose('/', methods=['PUT']) - @jwt_has_access + @protect + @safe @permission_name('put') def put(self, pk): item = self.datamodel.get(pk, self._base_filters) @@ -814,6 +746,9 @@ def put(self, pk): except ValidationError as err: return self.response(400, **{'message': err.messages}) else: + # This validates custom Schema with custom validations + if isinstance(item.data, dict): + return self.response(400, **{'message': item.errors}) self.pre_update(item.data) if self.datamodel.edit(item.data): self.post_add(item) @@ -826,7 +761,8 @@ def put(self, pk): return self.response_500() @expose('/', methods=['DELETE']) - @jwt_has_access + @protect + @safe @permission_name('delete') def delete(self, pk): item = self.datamodel.get(pk, self._base_filters) @@ -836,7 +772,7 @@ def delete(self, pk): self.pre_delete(item) if self.datamodel.delete(item): self.post_delete(item) - return self.response(200, **{'message': 'OK'}) + return self.response(200, message='OK') else: return self.response_500() @@ -904,7 +840,7 @@ def _get_item(self, pk, **kwargs): else: _show_model_schema = self.show_model_schema - _response['pk'] = pk + _response['id'] = pk _response['result'] = _show_model_schema.dump(item, many=False).data return self.response(200, **_response) @@ -917,7 +853,6 @@ def _get_list(self, **kwargs): _response = dict() _args = kwargs.get('rison', {}) # handle select columns - select_cols = _args.get('columns', []) _pruned_select_cols = [col for col in select_cols if col in self.list_columns] self.set_response_key_mappings( diff --git a/flask_appbuilder/security/api.py b/flask_appbuilder/security/api.py index ecce33554c..2c9daa1144 100644 --- a/flask_appbuilder/security/api.py +++ b/flask_appbuilder/security/api.py @@ -2,7 +2,7 @@ from flask_jwt_extended import create_access_token from flask_babel import lazy_gettext from ..views import expose -from ..api import BaseApi, ModelApi +from ..api import BaseApi, ModelRestApi class SecurityApi(BaseApi): @@ -45,8 +45,8 @@ def login(self): return self.response(200, access_token=access_token) -class UserApi(ModelApi): - route_base = '/api/v1/user' +class UserApi(ModelRestApi): + resource_name = 'user' label_columns = { 'get_full_name': lazy_gettext('Full Name'), diff --git a/flask_appbuilder/security/decorators.py b/flask_appbuilder/security/decorators.py index 83e37e4a8a..71b88b566d 100644 --- a/flask_appbuilder/security/decorators.py +++ b/flask_appbuilder/security/decorators.py @@ -10,26 +10,21 @@ log = logging.getLogger(__name__) -def jwt_has_access(f): +def protect(f): """ Use this decorator to enable granular security permissions - to your API methods. + to your API methods (BaseApi and child classes). Permissions will be associated to a role, and roles are associated to users. By default the permission's name is the methods name. """ if hasattr(f, '_permission_name'): - permission_str = "{}{}".format( - PERMISSION_PREFIX, - f._permission_name - ) + permission_str = f._permission_name else: - permission_str = "{}{}".format( - PERMISSION_PREFIX, - f.__name__ - ) + permission_str = f.__name__ def wraps(self, *args, **kwargs): + permission_str = "{}{}".format(PERMISSION_PREFIX, f._permission_name) if self.appbuilder.sm.is_item_public( permission_str, self.__class__.__name__ @@ -44,9 +39,13 @@ def wraps(self, *args, **kwargs): return f(self, *args, **kwargs) else: log.warning( - LOGMSG_ERR_SEC_ACCESS_DENIED.format(permission_str, self.__class__.__name__) + LOGMSG_ERR_SEC_ACCESS_DENIED.format( + permission_str, + self.__class__.__name__ + ) ) return self.response_401() + f._permission_name = permission_str return functools.update_wrapper(wraps, f) diff --git a/flask_appbuilder/security/views.py b/flask_appbuilder/security/views.py index adca422039..1325832ae9 100644 --- a/flask_appbuilder/security/views.py +++ b/flask_appbuilder/security/views.py @@ -9,7 +9,7 @@ from flask_login import login_user, logout_user from flask_jwt_extended import create_access_token from ..views import ModelView, SimpleFormView, expose -from ..api import BaseApi +from ..api import BaseApi, safe from ..baseviews import BaseView from ..charts.views import DirectByChartView from ..fieldwidgets import BS3PasswordFieldWidget @@ -27,23 +27,12 @@ class SecurityApi(BaseApi): route_base = '/api/v1/security' @expose('/login', methods=['POST']) + @safe def login(self): """ Login endpoint for the API returns a JWT :return: """ - # LOGIN_DB - # LDAP - # REMOTE USER ( is this possible secure? ) - # OAUTH ( is this done on the backend? ) - # AUTH0 ( trusted JWT we need a key to trust ) - - # Study asymmetric crypto option, good for AUTH0 - # Study refresh tokens - # https://flask-jwt-extended.readthedocs.io/en/latest/refresh_tokens.html#refresh-tokens - # https://tools.ietf.org/html/rfc7519 - # https://auth0.com/blog/json-web-token-signing-algorithms-overview/ - if not request.is_json: return self.response_400(message="Request payload is not JSON") username = request.json.get('username', None) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index e7883b8acd..09cdd17e35 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -22,7 +22,7 @@ def setUp(self): from flask import Flask from flask_appbuilder import AppBuilder from flask_appbuilder.models.sqla.interface import SQLAInterface - from flask_appbuilder.api import ModelApi + from flask_appbuilder.api import ModelRestApi self.app = Flask(__name__) self.basedir = os.path.abspath(os.path.dirname(__file__)) @@ -35,7 +35,7 @@ def setUp(self): # Create models and insert data insert_data(self.db.session, MODEL1_DATA_SIZE) - class Model1Api(ModelApi): + class Model1Api(ModelRestApi): datamodel = SQLAInterface(Model1) list_columns = [ 'field_integer', @@ -62,7 +62,7 @@ class Model1ApiFieldsInfo(Model1Api): 'field_integer' ] - class Model1FuncApi(ModelApi): + class Model1FuncApi(ModelRestApi): datamodel = SQLAInterface(Model1) list_columns = [ 'field_integer', @@ -77,7 +77,7 @@ class Model1FuncApi(ModelApi): 'field_string': 'Field String' } - class Model1ApiExcludeCols(ModelApi): + class Model1ApiExcludeCols(ModelRestApi): datamodel = SQLAInterface(Model1) list_exclude_columns = [ 'field_integer', @@ -88,11 +88,11 @@ class Model1ApiExcludeCols(ModelApi): edit_exclude_columns = list_exclude_columns add_exclude_columns = list_exclude_columns - class Model1ApiOrder(ModelApi): + class Model1ApiOrder(ModelRestApi): datamodel = SQLAInterface(Model1) base_order = ('field_integer', 'desc') - class Model1ApiFiltered(ModelApi): + class Model1ApiFiltered(ModelRestApi): datamodel = SQLAInterface(Model1) base_filters = [ ['field_integer', FilterGreater, 2], @@ -109,7 +109,7 @@ class Model1ApiFiltered(ModelApi): self.appbuilder.add_view_no_menu(Model1ApiFiltered) self.appbuilder.add_view_no_menu(Model1ApiExcludeCols) - class Model2Api(ModelApi): + class Model2Api(ModelRestApi): datamodel = SQLAInterface(Model2) list_columns = [ 'group' @@ -118,7 +118,7 @@ class Model2Api(ModelApi): 'group' ] - class Model2ApiFilteredRelFields(ModelApi): + class Model2ApiFilteredRelFields(ModelRestApi): datamodel = SQLAInterface(Model2) list_columns = [ 'group' diff --git a/flask_appbuilder/tests/test_base.py b/flask_appbuilder/tests/test_base.py index eafa8eb03e..cd9532fa55 100644 --- a/flask_appbuilder/tests/test_base.py +++ b/flask_appbuilder/tests/test_base.py @@ -267,7 +267,11 @@ def insert_data2(self): self.db.session.rollback() def insert_data3(self): - model3 = Model3(pk1=3, pk2=datetime.datetime(2017, 3, 3), field_string='foo') + model3 = Model3( + pk1=3, + pk2=datetime.datetime(2017, 3, 3), + field_string='foo' + ) try: self.db.session.add(model3) self.db.session.commit() From 3ec70d895d9a1c70c375ca64371fed32de4ae9a9 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 19 Mar 2019 23:40:34 +0000 Subject: [PATCH 031/109] [docs] New, api documentation --- docs/api.rst | 20 + docs/config.rst | 5 + docs/index.rst | 1 + docs/quickhowto.rst | 4 + docs/rest_api.rst | 877 ++++++++++++++++++++++++++++++ examples/base_api/README.rst | 10 + examples/base_api/app/__init__.py | 19 + examples/base_api/app/api.py | 50 ++ examples/base_api/app/models.py | 11 + examples/base_api/config.py | 56 ++ examples/base_api/run.py | 3 + 11 files changed, 1056 insertions(+) create mode 100644 docs/rest_api.rst create mode 100644 examples/base_api/README.rst create mode 100644 examples/base_api/app/__init__.py create mode 100644 examples/base_api/app/api.py create mode 100644 examples/base_api/app/models.py create mode 100644 examples/base_api/config.py create mode 100644 examples/base_api/run.py diff --git a/docs/api.rst b/docs/api.rst index 3883d9e5b3..8e95d02e58 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -20,6 +20,7 @@ flask_appbuilder.security.decorators .. automodule:: flask_appbuilder.security.decorators + .. autofunction:: protect .. autofunction:: has_access .. autofunction:: permission_name @@ -30,6 +31,25 @@ flask_appbuilder.models.decorators .. autofunction:: renders +flask_appbuilder.api +============================== + +.. automodule:: flask_appbuilder.api + + .. autofunction:: expose + +BaseApi +------- + +.. autoclass:: BaseApi + :members: + +ModelRestApi +------------ + +.. autoclass:: ModelApi + :members: + flask_appbuilder.baseviews ============================== diff --git a/docs/config.rst b/docs/config.rst index 0037aecd35..c757023d51 100755 --- a/docs/config.rst +++ b/docs/config.rst @@ -186,6 +186,11 @@ Use config.py to configure the following parameters. By default it will use SQLL | | the existing languages with the countries | | | | name and flag | | +-----------------------------------+--------------------------------------------+-----------+ +| FAB_API_SHOW_STACKTRACE | Sends api stack trace on uncaught | No | +| | exceptions. (Boolean) | | ++-----------------------------------+--------------------------------------------+-----------+ +| FAB_API_MAX_PAGE_SIZE | Sets a limit for FAB Model Api page size | No | ++-----------------------------------+--------------------------------------------+-----------+ Using config.py diff --git a/docs/index.rst b/docs/index.rst index 323716a2d3..d3d6382bcc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,6 +35,7 @@ Contents: views quickhowto + rest_api quickhowto_mongo quickcharts quickfiles diff --git a/docs/quickhowto.rst b/docs/quickhowto.rst index f5dd4ece6a..546b04ebf3 100644 --- a/docs/quickhowto.rst +++ b/docs/quickhowto.rst @@ -284,6 +284,10 @@ in case of success or errors. See the following table for a description of each REST API -------- +note: + This sort of automatic REST API is going to be deprecated, and will + be completely removed in 2 minors. + This API is still BETA and will be subject to change. In the future F.A.B. will probably use AngularJS to display the UI interface using AJAX. diff --git a/docs/rest_api.rst b/docs/rest_api.rst new file mode 100644 index 0000000000..22b927df3e --- /dev/null +++ b/docs/rest_api.rst @@ -0,0 +1,877 @@ +REST Api +======== + +On this chapter we are going to describe how you can define a RESTfull API +using almost the same concept as defining your MVC views. + +:note: + Follow this example on Flask-AppBuilder project ./examples/base_api/ + +First let's show you a basic example on how you can define your own +custom API endpoints:: + + + from flask_appbuilder.api import BaseApi, expose + from . import appbuilder + + + class MyFirstApi(BaseApi): + @expose('/greeting') + def greeting(self): + return self.response(200, message="Hello") + + + appbuilder.add_view_no_menu(MyFirstApi) + + +On the previous example, we are exposing an HTTP GET endpoint, +that returns the following JSON payload:: + + + { + "message": "Hello" + } + +The *@expose* decorator registers your class method as a Flask route that is going +to be associated with a Flask blueprint. A *BaseApi* class defines a blueprint that +contains all exposed methods. By default the base route of the class blueprint is +defined by: + +/api/v1/ + +So we can make a request to our method using:: + + $ curl http://localhost:8080/api/v1/myfirstapi/greeting + +To override the base route class blueprint, override the *base_route* property, +so on our previous example:: + + from flask_appbuilder.api import BaseApi, expose + from . import appbuilder + + + class MyFirstApi(BaseApi): + + base_route = '/newapi/v2/nice' + + @expose('/greeting') + def greeting(self): + return self.response(200, message="Hello") + + + appbuilder.add_view_no_menu(MyFirstApi) + +Now our endpoint will be:: + + $ curl http://localhost:8080/newapi/v2/nice/greeting + +We can also just override the version and/or resource name, +using *version* and *resource_name* properties:: + + from flask_appbuilder.api import BaseApi, expose + from . import appbuilder + + + class MyFirstApi(BaseApi): + + resource_name = 'nice' + + @expose('/greeting') + def greeting(self): + return self.response(200, message="Hello") + + + appbuilder.add_view_no_menu(MyFirstApi) + +Now our endpoint will be:: + + $ curl http://localhost:8080/api/v1/nice/greeting + + +The other HTTP methods (PUT, POST, DELETE, ...) can be defined just like +a Flask route signature:: + + from flask import request + from flask_appbuilder.api import BaseApi, expose + + class MyFirstApi(BaseApi): + + .... + + @expose('/greeting2', methods=['POST', 'GET']) + def greeting2(self): + if request.method == 'GET': + return self.response(200, message="Hello (GET)") + return self.response(201, message="Hello (POST)") + +The previous example will expose a new `greeting2` endpoint on HTTP GET and POST +so we can request it by:: + + $ curl http://localhost:8080/api/v1/myfirstapi/greeting2 + { + "message": "Hello (GET)" + } + $ curl -XPOST http://localhost:8080/api/v1/myfirstapi/greeting2 + { + "message": "Hello (POST)" + } + +Let's make our method a bit more interesting, and send our name on the HTTP +GET method. You can optionally use a *@rison* decorator that will parse +the HTTP URI arguments from a *rison* structure to a python data structure. +On this example it may seem a bit overboard but with *rison* we can handle +complex HTTP GET arguments in a human readable and predictable way. +Rison is a slight variation of JSON that looks vastly superior after URI encoding. +Rison still expresses exactly the same set of data structures as JSON, +so data can be translated back and forth without loss or guesswork:: + + from flask_appbuilder.api import BaseApi, expose, rison + + class MyFirstApi(BaseApi): + + ... + + @expose('/greeting3') + @rison + def greeting3(self, **kwargs): + if 'name' in kwargs['rison']: + return self.response( + 200, + message="Hello {}".format(kwargs['rison']['name']) + ) + return self.response_400(message="Please send your name") + +And to test our method:: + + $ curl 'http://localhost:8080/api/v1/myfirstapi/greeting3?q=(name:daniel)' + { + "message": "Hello daniel" + } + +To test this concept let's create a new method where we send a somewhat complex +data structure that will use numbers, booleans and lists, and send it back JSON formatted. +First our data structure, let's first think JSON:: + + { + "bool": true, + "list": ["a", "b", "c"], + "number": 777, + "string": "string" + "null": null + } + +On *Rison* format:: + + (bool:!t,list:!(a,b,c),null:!n,number:777,string:'string') + +Behind the scenes FAB is using *prison* a very nicely done fork developed by @betodealmeida +So using this package:: + + import prison + b = { + "bool": True, + "list": ["a", "b", "c"], + "number": 777, + "string": "string", + "null": None + + } + + print(prison.dumps(b)) + +So to test our concept:: + + ... + + @expose('/risonjson') + @rison + def rison_json(self, **kwargs): + return self.response(200, result=kwargs['rison']) + +Then call it:: + + $ curl 'http://localhost:8080/api/v1/myfirstapi/risonjson?q=(bool:!t,list:!(a,b,c),null:!n,number:777,string:'string')' + { + "result": { + "bool": true, + "list": [ + "a", + "b", + "c" + ], + "null": null, + "number": 777, + "string": "string" + } + } + + +Notice how the data types are preserved. Remember that we are building a Flask app +so you can always use *normal* URI arguments using Flask's *request.args* + +If we send an invalid *Rison* argument we get and error:: + + $ curl -v 'http://localhost:8080/api/v1/myfirstapi/risonjson?q=(bool:!t' + ... + < HTTP/1.0 400 BAD REQUEST + < Content-Type: application/json; charset=utf-8 + ... + { + "message": "Not valid rison argument" + } + +Finally to properly handle all possible exceptions use the *safe* decorator, +that will catch all uncaught exceptions for you and return a proper error response. +You can enable or disable stack trace response using the *FAB_API_SHOW_STACKTRACE* configuration +key:: + from flask_appbuilder.api import BaseApi, expose, rison, safe + + ... + + @expose('/error') + @safe + def error(self): + raise Exception + + +Security +-------- + +FAB offers user management, several authentication backends and granular role base access +so we can use these features on the API also. Default API authentication method is done +using JSON Web Tokens (JWT). + +:tip: + + FAB's JWT authentication is done with flask-jwt-extended. + Checkout it's documentation for custom configuration: + https://flask-jwt-extended.readthedocs.io/en/latest/options.html + +Next, let's see how to create a private method:: + + from flask import request + from flask_appbuilder.api import BaseApi, expose, rison + from flask_appbuilder.security.decorators import protect + from . import appbuilder + + + class MyFirstApi(BaseApi): + + ... + @expose('/private') + @protect + def rison_json(self): + return self.response(200, message="This is private") + + + appbuilder.add_view_no_menu(MyFirstApi) + +Accessing this method as expected will +return an HTTP 401 not authorized code and message:: + + $ curl -v 'http://localhost:8080/api/v1/myfirstapi/private' + ... + < HTTP/1.0 401 UNAUTHORIZED + < Content-Type: application/json + ... + { + "msg": "Missing Authorization Header" + } + +So we need to first obtain our JSON Web token, for this, FAB registers a login endpoint. +That we POST request with a JSON payload using:: + + { + "username": "", + "password": "", + "provider": "db|ldap" + } + +Notice the provider argument, FAB currently supports DB and LDAP authentication backends for the Api. + +Let's request our Token then:: + + # If not already, create an admin user + $ fabmanager create-admin + Username [admin]: + User first name [admin]: + User last name [user]: + Email [admin@fab.org]: + Password: + Repeat for confirmation: + ... + Admin User admin created. + + # Login to obtain a token + $ curl -XPOST http://localhost:8080/api/v1/security/login -d '{"username": "admin", "password": "password", "provider": "db"}' -H "Content-Type: application/json" + { + "access_token": "" + } + # It's nice to use the Token as an env var + $ export TOKEN="" + +Next we can use our token on protected endpoints:: + + $ curl 'http://localhost:8080/api/v1/myfirstapi/private' -H "Authorization: Bearer $TOKEN" + { + "message": "This is private" + } + +As always FAB created a new *can_private* permission on the DB and as associated it to the *Admin* Role. +So the Admin role as a new permission on view named "can private on MyFirstApi" +Note that you can protect all your methods and make them public or not by adding them to the *Public* Role. + +Model REST Api +-------------- + +To automatically create a RESTfull CRUD Api from a database Model, use *ModelRestApi* class and +define it almost like an MVC *ModelView*. This class will expose the following REST endpoints + + .. cssclass:: table-bordered table-hover + ++-----------------------------+-------------------------------------------------------+-----------------+--------+ +| URL | Description | Permission Name | HTTP | ++=============================+=======================================================+=================+========+ +| /_info | Returns info about the CRUD model and security | can_get | GET | ++-----------------------------+-------------------------------------------------------+-----------------+--------+ +| / | Queries models data, receives args as Rison | can_get | GET | ++-----------------------------+-------------------------------------------------------+-----------------+--------+ +| / | Returns a single model from it's primary key (id) | can_get | GET | ++-----------------------------+-------------------------------------------------------+-----------------+--------+ +| / | Receives a JSON payload as POST and creates record | can_post | POST | ++-----------------------------+-------------------------------------------------------+-----------------+--------+ +| / | Receives a JSON payload as PUT and updates record | can_put | PUT | ++-----------------------------+-------------------------------------------------------+-----------------+--------+ +| / | Deletes a single model from it's primary key (id) | can_delete | DELETE | ++-----------------------------+-------------------------------------------------------+-----------------+--------+ + +So for each *ModelRestApi* you will get 5 CRUD endpoints and an extra information method. +Let's dive into a simple example using the quickhowto. +The quickhowto example as a Contact's Model and a Group Model, so each Contact belongs to a Group. + +First let's define a CRUD REST Api for our Group model resource:: + + from flask_appbuilder.models.sqla.interface import SQLAInterface + from flask_appbuilder.api import ModelRestApi + from . import appbuilder + + + class GroupModelRestApi(ModelRestApi): + resource_name = 'group' + datamodel = SQLAInterface(ContactGroup) + + appbuilder.add_view_no_menu(MyFirstApi) + +Behind the scenes FAB uses marshmallow-sqlalchemy to infer the Model to a Marshmallow Schema, +that can be safely serialized and deserialized. Let's recall our Model definition for *ContactGroup*:: + + class ContactGroup(Model): + id = Column(Integer, primary_key=True) + name = Column(String(50), unique=True, nullable=False) + + def __repr__(self): + return self.name + + +All endpoints are protected so we need to request a JWT and use it on our REST resource, +like shown before we need to make a PUT request to the login API endpoint:: + + # Login to obtain a token + $ curl -XPOST http://localhost:8080/api/v1/security/login -d \ + '{"username": "admin", "password": "password", "provider": "db"}' \ + -H "Content-Type: application/json" + { + "access_token": "" + } + # It's nice to use the Token as an env var + $ export TOKEN="" + +First let's create a Group:: + + $ curl -XPOST http://localhost:8080/api/v1/group/ -d \ + '{"name": "Friends"}' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "id": 1, + "result": { + "name": "Friends" + } + } + +We got back a response with the model id and result with the inserted data. +Now let's query our newly created Group:: + + $ curl http://localhost:8080/api/v1/group/1 \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + + { + "description_columns": {}, + "include_columns": [ + "name" + ], + "label_columns": { + "name": "Name" + }, + "id": "1", + "result": { + "name": "Friends" + } + } + +As you can see, the API returns the model data, and extra meta data so you can properly render +a page with labels, descriptions and defined column order. This way it should be possible +to develop a React component (for example) that renders any model just by switching between HTTP endpoints. +It's possible to just ask for certain meta data keys, we will talk about this later. + +Next let's change our newly created model (This is a PUT method):: + + $ curl -XPUT http://localhost:8080/api/v1/group/1 -d \ + '{"name": "Friends Changed"}' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "result": { + "name": "Friends Changed" + } + } + +And finally test the delete method (HTTP DELETE):: + + $ curl -XDELETE http://localhost:8080/api/v1/group/1 \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "message": "OK" + } + +Let's check if it exists:: + + $ curl http://localhost:8080/api/v1/group/1 \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "message": "Not found" + } + + +We get an HTTP 404 (Not found). + +Validation and Custom Validation +-------------------------------- + +Notice that by using marshmallow with SQLAlchemy, +we are validating field size, type and required fields out of the box. +This is done by marshmallow-sqlalchemy that automatically creates ModelSchema's +inferred from our SQLAlchemy Models. +But you can always use your own defined Marshmallow schemas independently +for add, edit, list and show endpoints. + +A validation error for PUT and POST methods returns HTTP 400 and the following JSON data:: + + { + "message": { + "": [ + "", + ... + ], + ... + } + } + +Next we will test some basic validation, first the field type by sending a name that is a number:: + + $ curl XPOST http://localhost:8080/api/v1/group/ -d \ + '{"name": 1234}' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "message": { + "name": [ + "Not a valid string." + ] + } + } + +And we get an HTTP 400 (Bad request). + +How to add custom validation? On our next example we only allow +group names that start with a capital "A":: + + from marshmallow import Schema, fields, ValidationError, post_load + + + def validate_name(n): + if n[0] != 'A': + raise ValidationError('Name must start with an A') + + class GroupCustomSchema(Schema): + name = fields.Str(validate=validate_name) + + @post_load + def process(self, data): + return ContactGroup(**data) + +Then on our Api class:: + + class GroupModelRestApi(ModelRestApi): + resource_name = 'group' + add_model_schema = GroupCustomSchema() + edit_model_schema = GroupCustomSchema() + datamodel = SQLAInterface(ContactGroup) + +Let's try it out:: + + $ curl -v XPOST http://localhost:8080/api/v1/group/ -d \ + '{"name": "BOLA"}' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "message": { + "name": [ + "Name must start with an A" + ] + } + } + +Information endpoint +-------------------- + +This endpoint serves as a method to fetch meta information about our CRUD +methods. Again the main purpose to serve meta data is to make possible for a frontend +layer to be able to render dynamically search options, forms and to enable/disable +features based on permissions. First a birds eye view from the output of the **_info** endpoint:: + + { + "add_fields": [...], + "edit_fields": [...], + "filters": {...}, + "permissions": [...] + } + +Let's drill down this data structure, **add_fields** and **edit_fields** are similar +and serve to aid on rendering forms for add and edit so their response contains the +following data structure:: + + { + "add_fields": [ + { + "description": "", + "label": "", + "name": "", + "required": true|false, + "type": "String|Integer|Related|RelatedList|...", + "validate": [ ... list of validation methods ... ] + "values" : [ ... optional with all possible values for a related field ... ] + }, + ... + ] + } + +Edit fields **edit_fields** is similar, but it's content may be different, since +we can configure it in a distinct way + +Next, filters, this returns all the necessary info to render all possible filters allowed +by the backend database for each field on the model:: + + { + "filters": { + "": [ + { + "name": "", + "operator": "" + }, + ... + ], + ... + } + } + +Note that the **operator** value can be used to filter our list queries, +more about this later. + +Finally the permissions, this declares all allowed permissions for the current user. +Remember that these can extend the automatic HTTP methods generated by **ModelRestApi** +by just defining new methods and protecting them with the **protect** decorator:: + + { + "permissions": ["can_get", "can_put", ... ] + } + +On all GET HTTP methods we can select which meta data keys we want, this can +be done using *Rison* URI arguments. So the **_info** endpoint is not exception. +To across the board way to filter meta data is to send (using JSON):: + + { + "keys": [ ... LIST OF META DATA KEYS ... ] + } + +That translates to the following in *Rison* for fetching just the permissions meta data:: + + (keys:!(permissions)) + +So, back to our example:: + + $ curl 'http://localhost:8080/api/v1/group/_info?q=(keys:!(permissions))' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "permissions": [ + "can_get", + "can_post", + "can_put", + "can_delete" + ] + } + +And to fetch the permissions and Add form fields info:: + + $ curl 'http://localhost:8080/api/v1/group/_info?q=(keys:!(permissions,add_fields))' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "add_fields": [ ... ], + "permissions": [ + "can_get", + "can_post", + "can_put", + "can_delete" + ] + } + +To fetch meta data with internationalization use **_l_** URI key argument with i18n +country code as the value. This will work on any HTTP GET endpoint:: + + $ curl 'http://localhost:8080/api/v1/group/_info?q=(keys:!(permissions,add_fields))&_l_=pt' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "add_fields": [ ... ], + "permissions": [ + "can_get", + "can_post", + "can_put", + "can_delete" + ] + } + +Render meta data with *Portuguese*, labels, description, filters + +The **add_fields** and **edit_fields** keys also render all possible +values from related fields, using our *quickhowto* example:: + + { + "add_fields": [ + { + "description": "", + "label": "Gender", + "name": "gender", + "required": false, + "type": "Related", + "values": [ + { + "id": 1, + "value": "Male" + }, + { + "id": 2, + "value": "Female" + } + ] + }, + ... + ] + } + +These related field values can be filtered server side using the *add_query_rel_fields* +or **edit_query_rel_fields:: + + class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + add_query_rel_fields = { + 'gender': [['name', FilterStartsWith, 'F']] + } + +The previous example will filter out only the **Female** gender from our list +of possible values + +We can also restrict server side the available fields for add and edit using *add_columns* +and *edit_columns*. Additionally you can use *add_exclude_columns* and *edit_exclude_columns*:: + + class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + add_columns = ['name'] + +Will only return the field *name* from our *Contact* model information endpoint for *add_fields* + +Get Item +-------- + +The get item endpoint is very simple, and was already covered to some extent. +The response data structure is:: + + { + "id": "" + "description_columnns": {}, + "label_columns": {}, + "include_columns": [], + "result": {} + } + +Now we are going to cover the *Rison* arguments for custom fetching +meta data keys or columns. This time the accepted arguments is slightly extended:: + + { + "keys": [ ... List of meta data keys to return ... ], + "columns": [ ... List of columns to return ... ] + } + +So for fetching only the *name* and *address* for a certain *Contact*, using *Rison*:: + + (columns:!(name,address)) + +Our *curl* command will look like:: + + curl 'http://localhost:8080/api/v1/contact/1?q=(columns:!(name,address))' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "description_columns": {}, + "id": "1", + "include_columns": [ + "name", + "address" + ], + "label_columns": { + "address": "Address", + "name": "Name" + }, + "result": { + "address": "Street phoung", + "name": "Wilko Kamboh" + } + } + +And to only include the *label_columns* meta data, *Rison* data structure:: + + (columns:!(name,address),keys:!(label_columns)) + +Our *curl* command will look like:: + + curl 'http://localhost:8080/api/v1/contact/1?q=(columns:!(name,address),keys:!(label_columns))' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "id": "1", + "label_columns": { + "address": "Address", + "name": "Name" + }, + "result": { + "address": "Street phoung", + "name": "Wilko Kamboh" + } + } + +We can restrict or add fields for the get item endpoint using the *show_columns* property:: + + class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + show_columns = ['name'] + +We can add fields that are python functions also, for this on the SQLAlchemy definition, +let's add a new function:: + + class Contact(Model): + id = Column(Integer, primary_key=True) + name = Column(String(150), unique=True, nullable=False) + address = Column(String(564)) + birthday = Column(Date, nullable=True) + personal_phone = Column(String(20)) + personal_celphone = Column(String(20)) + contact_group_id = Column(Integer, ForeignKey('contact_group.id'), nullable=False) + contact_group = relationship("ContactGroup") + gender_id = Column(Integer, ForeignKey('gender.id'), nullable=False) + gender = relationship("Gender") + + def __repr__(self): + return self.name + + def some_function(self): + return "Hello {}".format(self.name) + +And then on the REST API:: + + + class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + show_columns = ['name', 'some_function'] + +Note that this can be done on the query list endpoint also using *list_columns* + +Lists and Queries +----------------- + +Finally for our last HTTP endpoint, and the most feature rich. +The response data structure is:: + + { + "count": + "ids": [ ... List of PK's ordered by result ... ], + "description_columnns": {}, + "label_columns": {}, + "list_columns": [ ... An ordered list of columns ...], + "order_columns": [ ... List of columns that can be ordered ... ], + "result": {} + } + +As before meta data can be chosen using *Rison* arguments:: + + (keys:!(label_columns)) + +Will only fetch the *label_columns* meta data key + +Like before, we can chose which columns to fetch:: + + (columns:!(name,address)) + +For ordering the results, the following will order contacts by name descending Z..A:: + + (order_column:name,order_direction:desc) + +Pagination, get the second page using page size of two (just an example):: + + (page:2,page_size:2) + +And last, but not least, *filters*. The query *filters* data structure:: + + { + "filters": [ + { + "col": , + "opr": , + "value": + }, + ... + ] + } + +All filters are **AND** operations. We can filter by several column names +using different operations, so using *Rison*:: + + (filters:!((col:name,opr:sw,value:a),(col:name,opr:ew,value:z))) + +The previous filter will query all contacts whose **name** starts with "a" and end with "z". +The possible operations for each field can be obtained from the information endpoint + +Note that all *Rison* arguments can be used alone or in combination:: + + (filters:!((col:name,opr:sw,value:a),(col:name,opr:ew,value:z)),columns:!(name),order_columns:name,order_direction:desc) + +Will filter all contacts whose **name** starts with "a" and end with "z", using descending name order by, and +just fetching the **name** column. diff --git a/examples/base_api/README.rst b/examples/base_api/README.rst new file mode 100644 index 0000000000..91960689d7 --- /dev/null +++ b/examples/base_api/README.rst @@ -0,0 +1,10 @@ +Base Api example +---------------- + +Simple example showing how to use *BaseApi* class + +Run it:: + + $ fabmanager run + + diff --git a/examples/base_api/app/__init__.py b/examples/base_api/app/__init__.py new file mode 100644 index 0000000000..c9dd15b7ec --- /dev/null +++ b/examples/base_api/app/__init__.py @@ -0,0 +1,19 @@ +import logging +from flask import Flask +from flask_appbuilder import SQLA, AppBuilder + +""" + Logging configuration +""" +logging.basicConfig(format='%(asctime)s:%(levelname)s:%(name)s:%(message)s') +logging.getLogger().setLevel(logging.DEBUG) + + +app = Flask(__name__) +app.config.from_object('config') +db = SQLA(app) +appbuilder = AppBuilder(app, db.session) + + +from . import api + diff --git a/examples/base_api/app/api.py b/examples/base_api/app/api.py new file mode 100644 index 0000000000..cb97227f9b --- /dev/null +++ b/examples/base_api/app/api.py @@ -0,0 +1,50 @@ +from flask import request +from flask_appbuilder.api import BaseApi, expose, rison, safe +from flask_appbuilder.security.decorators import protect +from . import appbuilder + + +class MyFirstApi(BaseApi): + + resource_name = 'myfirst' + version = 'v2' + route_base = '/newapi/v3/nice' + + @expose('/greeting') + def greeting(self): + return self.response(200, message="Hello") + + @expose('/greeting2', methods=['POST', 'GET']) + def greeting2(self): + if request.method == 'GET': + return self.response(200, message="Hello (GET)") + return self.response(201, message="Hello (POST)") + + @expose('/greeting3') + @rison + def greeting3(self, **kwargs): + if 'name' in kwargs['rison']: + return self.response( + 200, + message="Hello {}".format(kwargs['rison']['name']) + ) + return self.response_400(message="Please send your name") + + @expose('/risonjson') + @rison + def rison_json(self, **kwargs): + return self.response(200, result=kwargs['rison']) + + @expose('/private') + @protect + def private(self): + return self.response(200, message="This is private") + + @expose('/error') + @protect + @safe + def error(self): + raise Exception + + +appbuilder.add_view_no_menu(MyFirstApi) diff --git a/examples/base_api/app/models.py b/examples/base_api/app/models.py new file mode 100644 index 0000000000..453bc27177 --- /dev/null +++ b/examples/base_api/app/models.py @@ -0,0 +1,11 @@ +from flask_appbuilder import Model + +""" + +You can use the extra Flask-AppBuilder fields and Mixin's + +AuditMixin will add automatic timestamp of created and modified by who + + +""" + diff --git a/examples/base_api/config.py b/examples/base_api/config.py new file mode 100644 index 0000000000..1f3b147ee7 --- /dev/null +++ b/examples/base_api/config.py @@ -0,0 +1,56 @@ +import os +basedir = os.path.abspath(os.path.dirname(__file__)) + +CSRF_ENABLED = True +SECRET_KEY = '\2\1thisismyscretkey\1\2\e\y\y\h' + +OPENID_PROVIDERS = [ + { 'name': 'Google', 'url': 'https://www.google.com/accounts/o8/id' }, + { 'name': 'Yahoo', 'url': 'https://me.yahoo.com' }, + { 'name': 'AOL', 'url': 'http://openid.aol.com/' }, + { 'name': 'Flickr', 'url': 'http://www.flickr.com/' }, + { 'name': 'MyOpenID', 'url': 'https://www.myopenid.com' }] + +SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db') +#SQLALCHEMY_DATABASE_URI = 'mysql://myapp@localhost/myapp' +#SQLALCHEMY_DATABASE_URI = 'postgresql://root:password@localhost/myapp' +BABEL_DEFAULT_LOCALE = 'en' + + +#------------------------------ +# GLOBALS FOR APP Builder +#------------------------------ +BABEL_DEFAULT_LOCALE = 'en' +BABEL_DEFAULT_FOLDER = 'translations' +LANGUAGES = { + 'en': {'flag':'gb', 'name':'English'}, + 'pt': {'flag':'pt', 'name':'Portuguese'}, + 'es': {'flag':'es', 'name':'Spanish'}, + 'de': {'flag':'de', 'name':'German'}, + 'zh': {'flag':'cn', 'name':'Chinese'}, + 'ru': {'flag':'ru', 'name':'Russian'} +} + + +UPLOAD_FOLDER = basedir + '/app/static/uploads/' +IMG_UPLOAD_FOLDER = basedir + '/app/static/uploads/' +IMG_UPLOAD_URL = '/static/uploads/' +AUTH_TYPE = 1 +AUTH_ROLE_ADMIN = 'Admin' +AUTH_ROLE_PUBLIC = 'Public' +#APP_NAME = "My App Name" +#APP_ICON = "static/img/logo.jpg" +APP_THEME = "" # default +#APP_THEME = "cerulean.css" +#APP_THEME = "amelia.css" +#APP_THEME = "cosmo.css" +#APP_THEME = "cyborg.css" +#APP_THEME = "flatly.css" +#APP_THEME = "journal.css" +#APP_THEME = "readable.css" +#APP_THEME = "simplex.css" +#APP_THEME = "slate.css" +#APP_THEME = "spacelab.css" +#APP_THEME = "united.css" +#APP_THEME = "yeti.css" + diff --git a/examples/base_api/run.py b/examples/base_api/run.py new file mode 100644 index 0000000000..ddd39c1a08 --- /dev/null +++ b/examples/base_api/run.py @@ -0,0 +1,3 @@ +from . import app + +app.run(host='0.0.0.0', port=8080, debug=True) From 7301928988bdd521e18ce5ba71e71cf11317f43e Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 20 Mar 2019 10:44:57 +0000 Subject: [PATCH 032/109] [docs] New, api documentation --- docs/api.rst | 4 +- docs/rest_api.rst | 172 ++++++++++++++++++++++++++++------------ flask_appbuilder/api.py | 33 +++++++- 3 files changed, 157 insertions(+), 52 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 8e95d02e58..8e84aec48e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -37,6 +37,8 @@ flask_appbuilder.api .. automodule:: flask_appbuilder.api .. autofunction:: expose + .. autofunction:: rison + .. autofunction:: safe BaseApi ------- @@ -47,7 +49,7 @@ BaseApi ModelRestApi ------------ -.. autoclass:: ModelApi +.. autoclass:: ModelRestApi :members: flask_appbuilder.baseviews diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 22b927df3e..992f4238f7 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -7,7 +7,7 @@ using almost the same concept as defining your MVC views. :note: Follow this example on Flask-AppBuilder project ./examples/base_api/ -First let's show you a basic example on how you can define your own +First let's see a basic example on how you can define your own custom API endpoints:: @@ -32,8 +32,8 @@ that returns the following JSON payload:: "message": "Hello" } -The *@expose* decorator registers your class method as a Flask route that is going -to be associated with a Flask blueprint. A *BaseApi* class defines a blueprint that +The ``@expose`` decorator registers your class method as a Flask route that is going +to be associated with a Flask blueprint. A ``BaseApi`` class defines a blueprint that contains all exposed methods. By default the base route of the class blueprint is defined by: @@ -43,7 +43,7 @@ So we can make a request to our method using:: $ curl http://localhost:8080/api/v1/myfirstapi/greeting -To override the base route class blueprint, override the *base_route* property, +To override the base route class blueprint, override the ``base_route`` property, so on our previous example:: from flask_appbuilder.api import BaseApi, expose @@ -66,7 +66,7 @@ Now our endpoint will be:: $ curl http://localhost:8080/newapi/v2/nice/greeting We can also just override the version and/or resource name, -using *version* and *resource_name* properties:: +using ``version`` and ``resource_name`` properties:: from flask_appbuilder.api import BaseApi, expose from . import appbuilder @@ -117,11 +117,11 @@ so we can request it by:: } Let's make our method a bit more interesting, and send our name on the HTTP -GET method. You can optionally use a *@rison* decorator that will parse -the HTTP URI arguments from a *rison* structure to a python data structure. -On this example it may seem a bit overboard but with *rison* we can handle +GET method. You can optionally use a ``@rison`` decorator that will parse +the HTTP URI arguments from a *Rison* structure to a python data structure. +On this example it may seem a bit overboard but with *Rison* we can handle complex HTTP GET arguments in a human readable and predictable way. -Rison is a slight variation of JSON that looks vastly superior after URI encoding. +*Rison* is a slight variation of JSON that looks vastly superior after URI encoding. Rison still expresses exactly the same set of data structures as JSON, so data can be translated back and forth without loss or guesswork:: @@ -165,7 +165,7 @@ On *Rison* format:: (bool:!t,list:!(a,b,c),null:!n,number:777,string:'string') Behind the scenes FAB is using *prison* a very nicely done fork developed by @betodealmeida -So using this package:: +We can use this package, to help us dump or load python structures to Rison:: import prison b = { @@ -207,9 +207,9 @@ Then call it:: Notice how the data types are preserved. Remember that we are building a Flask app -so you can always use *normal* URI arguments using Flask's *request.args* +so you can always use *normal* URI arguments using Flask's ``request.args`` -If we send an invalid *Rison* argument we get and error:: +If we send an invalid *Rison* argument we get an error:: $ curl -v 'http://localhost:8080/api/v1/myfirstapi/risonjson?q=(bool:!t' ... @@ -220,10 +220,11 @@ If we send an invalid *Rison* argument we get and error:: "message": "Not valid rison argument" } -Finally to properly handle all possible exceptions use the *safe* decorator, +Finally to properly handle all possible exceptions use the ``safe`` decorator, that will catch all uncaught exceptions for you and return a proper error response. -You can enable or disable stack trace response using the *FAB_API_SHOW_STACKTRACE* configuration -key:: +You can enable or disable stack trace response using the +``FAB_API_SHOW_STACKTRACE`` configuration key:: + from flask_appbuilder.api import BaseApi, expose, rison, safe ... @@ -279,7 +280,7 @@ return an HTTP 401 not authorized code and message:: } So we need to first obtain our JSON Web token, for this, FAB registers a login endpoint. -That we POST request with a JSON payload using:: +For this we POST request with a JSON payload using:: { "username": "", @@ -287,7 +288,8 @@ That we POST request with a JSON payload using:: "provider": "db|ldap" } -Notice the provider argument, FAB currently supports DB and LDAP authentication backends for the Api. +Notice the *provider* argument, FAB currently supports DB and LDAP +authentication backends for the Api. Let's request our Token then:: @@ -303,7 +305,9 @@ Let's request our Token then:: Admin User admin created. # Login to obtain a token - $ curl -XPOST http://localhost:8080/api/v1/security/login -d '{"username": "admin", "password": "password", "provider": "db"}' -H "Content-Type: application/json" + $ curl -XPOST http://localhost:8080/api/v1/security/login -d \ + '{"username": "admin", "password": "password", "provider": "db"}' \ + -H "Content-Type: application/json" { "access_token": "" } @@ -317,15 +321,27 @@ Next we can use our token on protected endpoints:: "message": "This is private" } -As always FAB created a new *can_private* permission on the DB and as associated it to the *Admin* Role. -So the Admin role as a new permission on view named "can private on MyFirstApi" -Note that you can protect all your methods and make them public or not by adding them to the *Public* Role. +As always FAB created a new **can_private** permission +on the DB and as associated it to the *Admin* Role. +So the Admin role as a new permission on +a view named "can private on MyFirstApi" +Note that you can protect all your methods and make +them public or not by adding them to the *Public* Role. + +Also to restrict the default permissions we can use ``base_permissions`` +list property. This can be specially useful on ``ModelRestApi`` (up next) +where we can restrict our Api resources to be read only, or only allow POST +methods:: + + class MyFirstApi(BaseApi): + base_permissions = ['can_private'] + Model REST Api -------------- -To automatically create a RESTfull CRUD Api from a database Model, use *ModelRestApi* class and -define it almost like an MVC *ModelView*. This class will expose the following REST endpoints +To automatically create a RESTfull CRUD Api from a database *Model*, use ``ModelRestApi`` class and +define it almost like an MVC ``ModelView``. This class will expose the following REST endpoints .. cssclass:: table-bordered table-hover @@ -345,7 +361,7 @@ define it almost like an MVC *ModelView*. This class will expose the following R | / | Deletes a single model from it's primary key (id) | can_delete | DELETE | +-----------------------------+-------------------------------------------------------+-----------------+--------+ -So for each *ModelRestApi* you will get 5 CRUD endpoints and an extra information method. +For each ``ModelRestApi`` you will get 5 CRUD endpoints and an extra information method. Let's dive into a simple example using the quickhowto. The quickhowto example as a Contact's Model and a Group Model, so each Contact belongs to a Group. @@ -363,7 +379,7 @@ First let's define a CRUD REST Api for our Group model resource:: appbuilder.add_view_no_menu(MyFirstApi) Behind the scenes FAB uses marshmallow-sqlalchemy to infer the Model to a Marshmallow Schema, -that can be safely serialized and deserialized. Let's recall our Model definition for *ContactGroup*:: +that can be safely serialized and deserialized. Let's recall our Model definition for ``ContactGroup``:: class ContactGroup(Model): id = Column(Integer, primary_key=True) @@ -423,9 +439,9 @@ Now let's query our newly created Group:: As you can see, the API returns the model data, and extra meta data so you can properly render a page with labels, descriptions and defined column order. This way it should be possible to develop a React component (for example) that renders any model just by switching between HTTP endpoints. -It's possible to just ask for certain meta data keys, we will talk about this later. +It's also possible to just ask for certain meta data keys, we will talk about this later. -Next let's change our newly created model (This is a PUT method):: +Next let's change our newly created model (HTTP PUT):: $ curl -XPUT http://localhost:8080/api/v1/group/1 -d \ '{"name": "Friends Changed"}' \ @@ -446,7 +462,7 @@ And finally test the delete method (HTTP DELETE):: "message": "OK" } -Let's check if it exists:: +Let's check if it exists (HTTP GET):: $ curl http://localhost:8080/api/v1/group/1 \ -H "Content-Type: application/json" \ @@ -480,7 +496,8 @@ A validation error for PUT and POST methods returns HTTP 400 and the following J } } -Next we will test some basic validation, first the field type by sending a name that is a number:: +Next we will test some basic validation, first the field type +by sending a name that is a number:: $ curl XPOST http://localhost:8080/api/v1/group/ -d \ '{"name": 1234}' \ @@ -540,8 +557,15 @@ Information endpoint This endpoint serves as a method to fetch meta information about our CRUD methods. Again the main purpose to serve meta data is to make possible for a frontend -layer to be able to render dynamically search options, forms and to enable/disable -features based on permissions. First a birds eye view from the output of the **_info** endpoint:: +layer to be able to render dynamically: + +- Search options + +- Forms + +- Enable/disable features based on permissions. + +First a birds eye view from the output of the **_info** endpoint:: { "add_fields": [...], @@ -550,7 +574,7 @@ features based on permissions. First a birds eye view from the output of the **_ "permissions": [...] } -Let's drill down this data structure, **add_fields** and **edit_fields** are similar +Let's drill down this data structure, ``add_fields`` and ``edit_fields`` are similar and serve to aid on rendering forms for add and edit so their response contains the following data structure:: @@ -569,7 +593,7 @@ following data structure:: ] } -Edit fields **edit_fields** is similar, but it's content may be different, since +Edit fields ``edit_fields`` is similar, but it's content may be different, since we can configure it in a distinct way Next, filters, this returns all the necessary info to render all possible filters allowed @@ -592,8 +616,8 @@ Note that the **operator** value can be used to filter our list queries, more about this later. Finally the permissions, this declares all allowed permissions for the current user. -Remember that these can extend the automatic HTTP methods generated by **ModelRestApi** -by just defining new methods and protecting them with the **protect** decorator:: +Remember that these can extend the automatic HTTP methods generated by ``ModelRestApi`` +by just defining new methods and protecting them with the ``protect`` decorator:: { "permissions": ["can_get", "can_put", ... ] @@ -601,7 +625,8 @@ by just defining new methods and protecting them with the **protect** decorator: On all GET HTTP methods we can select which meta data keys we want, this can be done using *Rison* URI arguments. So the **_info** endpoint is not exception. -To across the board way to filter meta data is to send (using JSON):: +The across the board way to filter meta data is to send a GET request +using the following structure:: { "keys": [ ... LIST OF META DATA KEYS ... ] @@ -658,7 +683,7 @@ country code as the value. This will work on any HTTP GET endpoint:: Render meta data with *Portuguese*, labels, description, filters -The **add_fields** and **edit_fields** keys also render all possible +The ``add_fields`` and ``edit_fields`` keys also render all possible values from related fields, using our *quickhowto* example:: { @@ -684,8 +709,8 @@ values from related fields, using our *quickhowto* example:: ] } -These related field values can be filtered server side using the *add_query_rel_fields* -or **edit_query_rel_fields:: +These related field values can be filtered server side using the ``add_query_rel_fields`` +or ``edit_query_rel_fields``:: class ContactModelRestApi(ModelRestApi): resource_name = 'contact' @@ -697,15 +722,15 @@ or **edit_query_rel_fields:: The previous example will filter out only the **Female** gender from our list of possible values -We can also restrict server side the available fields for add and edit using *add_columns* -and *edit_columns*. Additionally you can use *add_exclude_columns* and *edit_exclude_columns*:: +We can also restrict server side the available fields for add and edit using ``add_columns`` +and ``edit_columns``. Additionally you can use ``add_exclude_columns`` and ``edit_exclude_columns``:: class ContactModelRestApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) add_columns = ['name'] -Will only return the field *name* from our *Contact* model information endpoint for *add_fields* +Will only return the field *name* from our *Contact* model information endpoint for ``add_fields`` Get Item -------- @@ -776,7 +801,8 @@ Our *curl* command will look like:: } } -We can restrict or add fields for the get item endpoint using the *show_columns* property:: +We can restrict or add fields for the get item endpoint using +the ``show_columns`` property. This takes precedence from the *Rison* arguments:: class ContactModelRestApi(ModelRestApi): resource_name = 'contact' @@ -806,13 +832,16 @@ let's add a new function:: And then on the REST API:: - class ContactModelRestApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) show_columns = ['name', 'some_function'] -Note that this can be done on the query list endpoint also using *list_columns* +The ``show_columns`` is also useful to impose an order on the columns. +Again this is useful to develop a dynamic frontend show item page/component +by using the *include_columns* meta data key. + +Note that this can be done on the query list endpoint also using ``list_columns`` Lists and Queries ----------------- @@ -836,18 +865,41 @@ As before meta data can be chosen using *Rison* arguments:: Will only fetch the *label_columns* meta data key -Like before, we can chose which columns to fetch:: +And we can chose which columns to fetch:: (columns:!(name,address)) +To reduce or extend the default inferred columns from our *Model*. +On server side we can use the ``list_columns`` property, +this takes precedence over *Rison* arguments:: + + class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + list_columns = ['name', 'address'] + For ordering the results, the following will order contacts by name descending Z..A:: (order_column:name,order_direction:desc) +To set a default order server side use ``base_order`` tuple:: + + class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + base_order = ('name', 'desc') + Pagination, get the second page using page size of two (just an example):: (page:2,page_size:2) +To set the default page size server side:: + + class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + page_size = 20 + And last, but not least, *filters*. The query *filters* data structure:: { @@ -866,12 +918,34 @@ using different operations, so using *Rison*:: (filters:!((col:name,opr:sw,value:a),(col:name,opr:ew,value:z))) -The previous filter will query all contacts whose **name** starts with "a" and end with "z". -The possible operations for each field can be obtained from the information endpoint +The previous filter will query all contacts whose **name** starts with "a" and ends with "z". +The possible operations for each field can be obtained from the information endpoint. +FAB can filter your models by any field type and all possible operations Note that all *Rison* arguments can be used alone or in combination:: (filters:!((col:name,opr:sw,value:a),(col:name,opr:ew,value:z)),columns:!(name),order_columns:name,order_direction:desc) -Will filter all contacts whose **name** starts with "a" and end with "z", using descending name order by, and +Will filter all contacts whose **name** starts with "a" and ends with "z", using descending name order by, and just fetching the **name** column. + +To impose base filters server side:: + + class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + base_filters = [['name', FilterStartsWith, 'A']] + +The filter will act on all HTTP endpoints, protecting delete, create, update and display +operations + +Simple example using doted notation, FAB will infer the necessary join operation:: + + class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + base_filters = [['contact_group.name', FilterStartsWith, 'F']] + +Locks all contacts, to groups whose name starts with "F". Using the provided test data +on the quickhowto example, limits the contacts to family and friends. + diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 6c484be1c6..7360fe93df 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -42,6 +42,18 @@ def wraps(self, *args, **kwargs): def rison(f): + """ + Use this decorator to parse URI *Rison* arguments to + a python data structure, you're method gets the data + structure on kwargs['rison']. Response is HTTP 400 + if *Rison* is not correct:: + + class ExampleApi(BaseApi): + @expose('/risonjson') + @rison + def rison_json(self, **kwargs): + return self.response(200, result=kwargs['rison']) + """ def wraps(self, *args, **kwargs): value = request.args.get(URI_RISON_KEY, None) @@ -112,16 +124,26 @@ class BaseApi: endpoint = None version = 'v1' + """ + Define the Api version for this resource/class + """ route_base = None """ - Define the route base where all methods will sufix from + Define the route base where all methods will suffix from """ resource_name = None - """ + """ Defines a custom resource name, overrides the inferred from Class name makes no sense to use it with route base """ base_permissions = None + """ + A list of allowed base permissions:: + + class ExampleApi(BaseApi): + base_permissions = ['can_get'] + + """ extra_args = None def __init__(self): @@ -234,6 +256,13 @@ def merge_current_user_permissions(self, response, **kwargs): @staticmethod def response(code, **kwargs): + """ + Generic HTTP JSON response method + + :param code: HTTP code (int) + :param kwargs: Data structure for response (dict) + :return: None + """ _ret_json = jsonify(kwargs) resp = make_response(_ret_json, code) resp.headers['Content-Type'] = "application/json; charset=utf-8" From 2dc87e9877248c7a0ade26045184411c9a744916 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 20 Mar 2019 11:09:32 +0000 Subject: [PATCH 033/109] [api] Fix, BaseApi is child of object for 2.7 compatibility --- NOTE.txt | 11 +++++++ examples/quickhowto/app/models.py | 20 ++++++++++-- examples/quickhowto/app/views.py | 53 ++++++++++++++++--------------- flask_appbuilder/api.py | 2 +- 4 files changed, 58 insertions(+), 28 deletions(-) create mode 100644 NOTE.txt diff --git a/NOTE.txt b/NOTE.txt new file mode 100644 index 0000000000..1d6e11e909 --- /dev/null +++ b/NOTE.txt @@ -0,0 +1,11 @@ +# LOGIN_DB +# LDAP +# REMOTE USER ( is this possible secure? ) +# OAUTH ( is this done on the backend? ) +# AUTH0 ( trusted JWT we need a key to trust ) + +# Study asymmetric crypto option, good for AUTH0 +# Study refresh tokens +# https://flask-jwt-extended.readthedocs.io/en/latest/refresh_tokens.html#refresh-tokens +# https://tools.ietf.org/html/rfc7519 +# https://auth0.com/blog/json-web-token-signing-algorithms-overview/ diff --git a/examples/quickhowto/app/models.py b/examples/quickhowto/app/models.py index fae3384da2..0f523a2e14 100644 --- a/examples/quickhowto/app/models.py +++ b/examples/quickhowto/app/models.py @@ -2,11 +2,16 @@ from sqlalchemy import Column, Integer, String, ForeignKey, Date from sqlalchemy.orm import relationship from flask_appbuilder import Model -from marshmallow import Schema, fields +from marshmallow import Schema, fields, ValidationError, post_load, pre_load mindate = datetime.date(datetime.MINYEAR, 1, 1) +def validate_name(n): + if n[0] != 'A': + raise ValidationError('Name must start with an A') + + class ContactGroup(Model): id = Column(Integer, primary_key=True) name = Column(String(50), unique=True, nullable=False) @@ -15,6 +20,14 @@ def __repr__(self): return self.name +class GroupCustomSchema(Schema): + name = fields.Str(validate=validate_name) + + @post_load + def process(self, data): + return ContactGroup(**data) + + class ContactGroupSchema(Schema): name = fields.Str(required=True) @@ -29,7 +42,7 @@ def __repr__(self): class Contact(Model): id = Column(Integer, primary_key=True) - name = Column(String(150), unique = True, nullable=False) + name = Column(String(150), unique=True, nullable=False) address = Column(String(564)) birthday = Column(Date, nullable=True) personal_phone = Column(String(20)) @@ -46,6 +59,9 @@ def month_year(self): date = self.birthday or mindate return datetime.datetime(date.year, date.month, 1) or mindate + def some_function(self): + return "Hello {}".format(self.name) + def year(self): date = self.birthday or mindate return datetime.datetime(date.year, 1, 1) diff --git a/examples/quickhowto/app/views.py b/examples/quickhowto/app/views.py index afb9dda5cc..79e088766a 100644 --- a/examples/quickhowto/app/views.py +++ b/examples/quickhowto/app/views.py @@ -4,12 +4,12 @@ from flask_appbuilder.charts.views import GroupByChartView from flask_appbuilder.models.group import aggregate_count from flask_babel import lazy_gettext as _ -from flask_appbuilder.api import ModelApi +from flask_appbuilder.api import ModelRestApi from flask_appbuilder.security.sqla.models import User from flask_appbuilder.models.sqla.filters import FilterStartsWith, FilterEqualFunction -from app import db, appbuilder, app -from .models import ContactGroup, Gender, Contact, ContactGroupSchema, ContactSchema +from . import db, appbuilder, app +from .models import ContactGroup, Gender, Contact, ContactGroupSchema, ContactSchema, GroupCustomSchema def fill_gender(): @@ -54,27 +54,31 @@ class GroupModelView(ModelView): related_views = [ContactModelView] -class GroupModelApi(ModelApi): +class GroupModelRestApi(ModelRestApi): + resource_name = 'group' + add_model_schema = GroupCustomSchema() + edit_model_schema = GroupCustomSchema() datamodel = SQLAInterface(ContactGroup) -class ContactModelApi(ModelApi): +class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' datamodel = SQLAInterface(Contact) - #show_columns = ['name'] + list_columns = ['name', 'contact_group'] + base_filters = [['contact_group.name', FilterStartsWith, 'F']] #list_model_schema = ContactSchema() - base_filters = [['name', FilterStartsWith, 'a']] - add_query_rel_fields = { - 'contact_group': [['name', FilterStartsWith, 'F']] - } - edit_query_rel_fields = { - 'contact_group': [['name', FilterStartsWith, 'F']] - } + #base_filters = [['name', FilterStartsWith, 'a']] + #add_query_rel_fields = { + # 'contact_group': [['name', FilterStartsWith, 'F']] + #} + #edit_query_rel_fields = { + # 'contact_group': [['name', FilterStartsWith, 'F']] + #} - list_columns = ['name', 'address', 'personal_celphone'] - - -class UserModelApi(ModelApi): - datamodel = SQLAInterface(User) + #list_columns = ['name', 'address', 'personal_celphone'] + #base_order = ('name', 'desc') + #list_exclude_columns = ['gender', 'contact_group_id','gender_id', 'id'] + #show_exclude_columns = ['name'] class ContactChartView(GroupByChartView): @@ -125,9 +129,8 @@ class ContactTimeChartView(GroupByChartView): db.create_all() fill_gender() -appbuilder.add_view_no_menu(GroupModelApi) -appbuilder.add_view_no_menu(UserModelApi) -appbuilder.add_view_no_menu(ContactModelApi) +appbuilder.add_view_no_menu(GroupModelRestApi) +appbuilder.add_view_no_menu(ContactModelRestApi) appbuilder.add_view(GroupModelView, "List Groups", icon="fa-folder-open-o", category="Contacts", category_icon='fa-envelope') appbuilder.add_view(ContactModelView, "List Contacts", icon="fa-envelope", category="Contacts") @@ -138,8 +141,8 @@ class ContactTimeChartView(GroupByChartView): @app.after_request def after_request(response): - response.headers.add('Access-Control-Allow-Origin', '*') - response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') - response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') - return response + response.headers.add('Access-Control-Allow-Origin', '*') + response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') + response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') + return response diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 7360fe93df..b68f859447 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -109,7 +109,7 @@ def wrap(f): return wrap -class BaseApi: +class BaseApi(object): """ All apis inherit from this class. it's constructor will register your exposed urls on flask From 3dac29e35380f0f71b0ff82ec1bd6ea1ed0c6ac5 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 20 Mar 2019 11:28:56 +0000 Subject: [PATCH 034/109] [tests][mvc] Fix, redirect test this needs to be revisited --- examples/quickhowto/app/views.py | 4 ++++ flask_appbuilder/tests/test_base.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/quickhowto/app/views.py b/examples/quickhowto/app/views.py index 79e088766a..bd89e36a9b 100644 --- a/examples/quickhowto/app/views.py +++ b/examples/quickhowto/app/views.py @@ -1,5 +1,6 @@ import calendar from flask_appbuilder import ModelView +from flask_appbuilder.views import redirect from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_appbuilder.charts.views import GroupByChartView from flask_appbuilder.models.group import aggregate_count @@ -24,6 +25,9 @@ def fill_gender(): class ContactModelView(ModelView): datamodel = SQLAInterface(Contact) + def post_add_redirect(self): + return redirect('model1viewwithredirects/show/{0}'.format(99999)) + list_columns = ['name', 'personal_celphone', 'birthday', 'contact_group.name'] base_order = ('name', 'asc') diff --git a/flask_appbuilder/tests/test_base.py b/flask_appbuilder/tests/test_base.py index cd9532fa55..4e92d4afb8 100644 --- a/flask_appbuilder/tests/test_base.py +++ b/flask_appbuilder/tests/test_base.py @@ -35,7 +35,7 @@ NOTNULL_VALIDATION_STRING = 'This field is required' DEFAULT_ADMIN_USER = 'admin' DEFAULT_ADMIN_PASSWORD = 'general' -REDIRECT_OBJ_ID = 99999 +REDIRECT_OBJ_ID = 1 log = logging.getLogger(__name__) From 982abad02398d4557faca2a99490002138c82239 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 20 Mar 2019 11:53:02 +0000 Subject: [PATCH 035/109] [ci] New, tox config --- tox.ini | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tox.ini diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000000..2bd467f246 --- /dev/null +++ b/tox.ini @@ -0,0 +1,38 @@ +[flake8] +accept-encodings = utf-8 +exclude = + .tox + build + docs + examples + flask_appbuilder/templates + flask_appbuilder/static + venv +ignore = + FI12 + FI15 + FI16 + FI17 + FI50 + FI51 + FI53 + FI54 + W504 + W605 +import-order-style = google +max-line-length = 90 +require-code = true + +[testenv:flake8] +commands = + flake8 {toxinidir}/ + +[testenv:apitest] +commands = + nosetests -v --with-coverage --cover-package flask_appbuilder.api flask_appbuilder.tests.test_api + +[tox] +envlist = + flake8 + apitest + From 18eaa75493c28d5de58f66b8c666a4d2b75f166d Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 20 Mar 2019 12:10:33 +0000 Subject: [PATCH 036/109] [ci] Fix, typo --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 14a82cc2ce..93b0722406 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ def desc(): 'flask-marshmallow==0.9.0', 'marshmallow==2.18.0', 'marshmallow-sqlalchemy==0.15.0', - 'prison==0.1.0' + 'prison==0.1.0', 'PyJWT>=1.7.1' ], tests_require=[ From aeb3943f8b7acc67721be0ccd61bf8eb7613d8e6 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 20 Mar 2019 12:21:25 +0000 Subject: [PATCH 037/109] [ci] Fix, missing import --- flask_appbuilder/security/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_appbuilder/security/decorators.py b/flask_appbuilder/security/decorators.py index 2a15e4dd65..c8492d25b5 100644 --- a/flask_appbuilder/security/decorators.py +++ b/flask_appbuilder/security/decorators.py @@ -1,7 +1,7 @@ import logging import functools -from flask import flash, redirect, url_for, make_response, jsonify +from flask import flash, redirect, url_for, make_response, jsonify, request from flask_jwt_extended import verify_jwt_in_request, get_jwt_identity from flask_login import login_user from .._compat import as_unicode From 0b83cefd1f07cbe57ea8f2c6434c2f947481ea37 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 20 Mar 2019 18:37:27 +0000 Subject: [PATCH 038/109] [style] Fix, small improvements on code comments and PEP8, style --- flask_appbuilder/api.py | 213 ++++++++++++++++++++++------------------ 1 file changed, 119 insertions(+), 94 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index b68f859447..b7f8cd0ea2 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -261,7 +261,7 @@ def response(code, **kwargs): :param code: HTTP code (int) :param kwargs: Data structure for response (dict) - :return: None + :return: HTTP Json response """ _ret_json = jsonify(kwargs) resp = make_response(_ret_json, code) @@ -269,16 +269,40 @@ def response(code, **kwargs): return resp def response_400(self, message=None): + """ + Helper method for HTTP 400 response + + :param message: Error message (str) + :return: HTTP Json response + """ message = message or "Arguments are not correct" return self.response(400, **{"message": message}) def response_401(self): + """ + Helper method for HTTP 401 response + + :param message: Error message (str) + :return: HTTP Json response + """ return self.response(401, **{"message": "Not authorized"}) def response_404(self): + """ + Helper method for HTTP 404 response + + :param message: Error message (str) + :return: HTTP Json response + """ return self.response(404, **{"message": "Not found"}) def response_500(self, message=None): + """ + Helper method for HTTP 500 response + + :param message: Error message (str) + :return: HTTP Json response + """ message = message or "Internal error" return self.response(500, **{"message": message}) @@ -352,11 +376,6 @@ class MyView(ModelRestApi): Filters object will calculate all possible filter types based on search_columns """ - list_model_schema = None - add_model_schema = None - edit_model_schema = None - show_model_schema = None - def __init__(self, **kwargs): """ Constructor @@ -393,7 +412,8 @@ def _init_properties(self): self.search_exclude_columns = self.search_exclude_columns or [] self.search_columns = self.search_columns or [] - self._base_filters = self.datamodel.get_filters().add_filter_list(self.base_filters) + self._base_filters = \ + self.datamodel.get_filters().add_filter_list(self.base_filters) list_cols = self.datamodel.get_columns_list() search_columns = self.datamodel.get_search_columns_list() if not self.search_columns: @@ -497,7 +517,7 @@ class MyView(ModelView): {'relation col name':[['Related model col',FilterClass,'Filter Value'],...],...} Add a custom filter to form related fields:: - class ContactModelView(ModelView): + class ContactModelView(ModelRestApi): datamodel = SQLAModel(Contact, db.session) add_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} @@ -511,11 +531,31 @@ class ContactModelView(ModelView): {'relation col name':[['Related model col',FilterClass,'Filter Value'],...],...} Add a custom filter to form related fields:: - class ContactModelView(ModelView): + class ContactModelView(ModelRestApi): datamodel = SQLAModel(Contact, db.session) edit_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} """ + list_model_schema = None + """ + Override to provide your own marshmallow Schema + for JSON to SQLA dumps + """ + add_model_schema = None + """ + Override to provide your own marshmallow Schema + for JSON to SQLA dumps + """ + edit_model_schema = None + """ + Override to provide your own marshmallow Schema + for JSON to SQLA dumps + """ + show_model_schema = None + """ + Override to provide your own marshmallow Schema + for JSON to SQLA dumps + """ def create_blueprint(self, appbuilder, *args, **kwargs): self._init_model_schemas() @@ -632,98 +672,24 @@ def merge_search_filters(self, response, **kwargs): ] response['filters'] = search_filters - def _get_field_info(self, field, filter_rel_field): - """ - Return a dict with field details - ready to serve as a response - - :param field: marshmallow field - :return: dict with field details - """ - from marshmallow_sqlalchemy.fields import Related, RelatedList - ret = dict() - ret['name'] = field.name - ret['label'] = self.label_columns.get(field.name, '') - ret['description'] = self.description_columns.get(field.name, '') - # Handles related fields - if isinstance(field, Related) or isinstance(field, RelatedList): - _rel_interface = self.datamodel.get_related_interface(field.name) - _filters = _rel_interface.get_filters(_rel_interface.get_search_columns_list()) - if filter_rel_field: - filters = _filters.add_filter_list(filter_rel_field) - _values = _rel_interface.query(filters)[1] - else: - _values = _rel_interface.query()[1] - ret['values'] = list() - for _value in _values: - ret['values'].append( - { - "id": _rel_interface.get_pk_value(_value), - "value": str(_value) - } - ) - - if field.validate and isinstance(field.validate, list): - ret['validate'] = [str(v) for v in field.validate] - elif field.validate: - ret['validate'] = [str(field.validate)] - ret['type'] = field.__class__.__name__ - ret['required'] = field.required - return ret - - def _get_fields_info(self, cols, model_schema, filter_rel_fields): - """ - Returns a dict with fields detail - from a marshmallow schema - - :param cols: list of columns to show info for - :param model_schema: Marshmallow model schema - :param filter_rel_fields: expects add_query_rel_fields or - edit_query_rel_fields - :return: dict with all fields details - """ - return [ - self._get_field_info( - model_schema.fields[col], - filter_rel_fields.get(col, []) - ) - for col in cols - ] - @expose('/_info', methods=['GET']) @protect @rison @safe @permission_name('get') - @merge_response_func( - BaseApi.merge_current_user_permissions, - 'permissions' - ) - @merge_response_func( - merge_add_field_info, - 'add_fields' - ) - @merge_response_func( - merge_edit_field_info, - 'edit_fields' - ) - @merge_response_func( - merge_search_filters, - "filters" - ) + @merge_response_func(BaseApi.merge_current_user_permissions, 'permissions') + @merge_response_func(merge_add_field_info, 'add_fields') + @merge_response_func(merge_edit_field_info, 'edit_fields') + @merge_response_func(merge_search_filters, "filters") def info(self, **kwargs): + """ + Endpoint that renders a response for CRUD REST meta data + :param kwargs: Rison kwargs + """ _response = dict() _args = kwargs.get('rison', {}) - self.set_response_key_mappings( - _response, - self.info, - _args, - **{} - ) - return self.response( - 200, - **_response - ) + self.set_response_key_mappings(_response, self.info, _args, **{}) + return self.response(200, **_response) @expose('/', methods=['GET']) @expose('/', methods=['GET']) @@ -904,9 +870,10 @@ def _get_list(self, **kwargs): order_direction = _args.get('order_direction', '') if not order_column and self.base_order: order_column, order_direction = self.base_order - # Make the query + # handle pagination page_index = _args.get('page', 0) page_size = _args.get('page_size', self.page_size) + # Make the query count, lst = self.datamodel.query(joined_filters, order_column, order_direction, @@ -934,6 +901,64 @@ def _description_columns_json(self, cols=None): ret[key] = as_unicode(_(value).encode('UTF-8')) return ret + def _get_field_info(self, field, filter_rel_field): + """ + Return a dict with field details + ready to serve as a response + + :param field: marshmallow field + :return: dict with field details + """ + from marshmallow_sqlalchemy.fields import Related, RelatedList + ret = dict() + ret['name'] = field.name + ret['label'] = self.label_columns.get(field.name, '') + ret['description'] = self.description_columns.get(field.name, '') + # Handles related fields + if isinstance(field, Related) or isinstance(field, RelatedList): + _rel_interface = self.datamodel.get_related_interface(field.name) + _filters = _rel_interface.get_filters(_rel_interface.get_search_columns_list()) + if filter_rel_field: + filters = _filters.add_filter_list(filter_rel_field) + _values = _rel_interface.query(filters)[1] + else: + _values = _rel_interface.query()[1] + ret['values'] = list() + for _value in _values: + ret['values'].append( + { + "id": _rel_interface.get_pk_value(_value), + "value": str(_value) + } + ) + + if field.validate and isinstance(field.validate, list): + ret['validate'] = [str(v) for v in field.validate] + elif field.validate: + ret['validate'] = [str(field.validate)] + ret['type'] = field.__class__.__name__ + ret['required'] = field.required + return ret + + def _get_fields_info(self, cols, model_schema, filter_rel_fields): + """ + Returns a dict with fields detail + from a marshmallow schema + + :param cols: list of columns to show info for + :param model_schema: Marshmallow model schema + :param filter_rel_fields: expects add_query_rel_fields or + edit_query_rel_fields + :return: dict with all fields details + """ + return [ + self._get_field_info( + model_schema.fields[col], + filter_rel_fields.get(col, []) + ) + for col in cols + ] + def pre_update(self, item): """ Override this, this method is called before the update takes place. From f0be26fa0e3ea42a3bb6771375cfd8435993694b Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 20 Mar 2019 18:38:37 +0000 Subject: [PATCH 039/109] [api] New, FAB_API_MAX_PAGE_SIZE default init (part 1) --- flask_appbuilder/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index 8874d976b9..35014942f6 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -153,7 +153,8 @@ def init_app(self, app, session): app.config.setdefault('APP_ICON', '') app.config.setdefault('LANGUAGES', {'en': {'flag': 'gb', 'name': 'English'}}) - app.config.setdefault('ADDON_MANAGERS',[]) + app.config.setdefault('ADDON_MANAGERS', []) + app.config.setdefault('FAB_API_MAX_PAGE_SIZE', 20) if self.security_manager_class is None: from flask_appbuilder.security.sqla.manager import SecurityManager self.security_manager_class = SecurityManager From b830b4dd51a09e20e8fae57767e20760b0c50872 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 21 Mar 2019 05:54:48 +0000 Subject: [PATCH 040/109] [ci] Fix, lock dependencies to avoid breaking tests --- .travis.yml | 1 + requirements.txt | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 requirements.txt diff --git a/.travis.yml b/.travis.yml index 7814b0d812..cdc9a3883c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ python: install: - pip install --upgrade 'pip>=9.0.1,<9.99' - pip -V + - pip install -r requirements.txt - python setup.py install - pip install coveralls - pip install 'mongoengine>=0.7.10,<0.7.99' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..51a98f3fef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,42 @@ +Babel==2.6.0 +certifi==2019.3.9 +chardet==3.0.4 +Click==7.0 +colorama==0.4.1 +coverage==4.5.3 +coveralls==1.7.0 +defusedxml==0.5.0 +docopt==0.6.2 +Flask==1.0.2 +Flask-Babel==0.12.2 +Flask-JWT-Extended==3.18.0 +Flask-Login==0.4.1 +flask-marshmallow==0.9.0 +flask-mongoengine==0.7.1 +Flask-OpenID==1.2.5 +Flask-SQLAlchemy==2.3.2 +Flask-WTF==0.14.2 +funcparserlib==0.3.6 +idna==2.8 +itsdangerous==1.1.0 +Jinja2==2.10 +MarkupSafe==1.1.1 +marshmallow==2.18.0 +marshmallow-sqlalchemy==0.15.0 +mockldap==0.3.0 +mongoengine==0.7.10 +nose==1.3.7 +Pillow==3.4.2 +prison==0.1.0 +pyasn1==0.4.5 +pyasn1-modules==0.2.4 +PyJWT==1.7.1 +pymongo==2.8.1 +python-dateutil==2.8.0 +pytz==2018.9 +requests==2.21.0 +six==1.12.0 +SQLAlchemy==1.3.1 +Werkzeug==0.14.1 +WTForms==2.2.1 + From 67df926492c1e51f7262e85cccac3bf88513626f Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 21 Mar 2019 17:37:32 +0000 Subject: [PATCH 041/109] [api] New, FAB_API_MAX_PAGE_SIZE config key --- examples/quickhowto/config.py | 1 + flask_appbuilder/api.py | 19 ++++++-- flask_appbuilder/tests/test_api.py | 75 +++++++++++++++++++----------- 3 files changed, 65 insertions(+), 30 deletions(-) diff --git a/examples/quickhowto/config.py b/examples/quickhowto/config.py index d7fce5cd30..70497d5e11 100644 --- a/examples/quickhowto/config.py +++ b/examples/quickhowto/config.py @@ -35,6 +35,7 @@ 'ja_JP': {'flag': 'jp', 'name': 'Japanese'} } +FAB_API_MAX_PAGE_SIZE = 30 #------------------------------ # GLOBALS FOR GENERAL APP's diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index b7f8cd0ea2..7c62ff2925 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -750,7 +750,7 @@ def put(self, pk): self.post_update(item) return self.response( 200, - **{'result': self.edit_model_schema.dump(item.data, many=False).data} + result=self.edit_model_schema.dump(item.data, many=False).data ) else: return self.response_500() @@ -871,8 +871,7 @@ def _get_list(self, **kwargs): if not order_column and self.base_order: order_column, order_direction = self.base_order # handle pagination - page_index = _args.get('page', 0) - page_size = _args.get('page_size', self.page_size) + page_index, page_size = self._handle_page_args(_args) # Make the query count, lst = self.datamodel.query(joined_filters, order_column, @@ -890,6 +889,20 @@ def _get_list(self, **kwargs): HELPER FUNCTIONS ------------------------------------------------ """ + def _handle_page_args(self, rison_args): + """ + Helper function to handle page rison page + argument, sets defaults and impose MAX_PAGE_SIZE + :param args: + :return: (tuple) page, page_size + """ + page_index = rison_args.get('page', 0) + page_size = rison_args.get('page_size', self.page_size) + max_page_size = current_app.config.get('FAB_API_MAX_PAGE_SIZE') + if page_size > max_page_size: + page_size = max_page_size + return page_index, page_size + def _description_columns_json(self, cols=None): """ Prepares dict with col descriptions to be JSON serializable diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 09cdd17e35..2e1edb73f6 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -10,10 +10,11 @@ log = logging.getLogger(__name__) -MODEL1_DATA_SIZE = 10 -MODEL2_DATA_SIZE = 10 +MODEL1_DATA_SIZE = 20 +MODEL2_DATA_SIZE = 20 USERNAME = "testadmin" PASSWORD = "password" +MAX_PAGE_SIZE = 10 class FlaskTestCase(unittest.TestCase): @@ -29,6 +30,7 @@ def setUp(self): self.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' self.app.config['SECRET_KEY'] = 'thisismyscretkey' self.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + self.app.config['FAB_API_MAX_PAGE_SIZE'] = MAX_PAGE_SIZE self.db = SQLA(self.app) self.appbuilder = AppBuilder(self.app, self.db.session) @@ -325,7 +327,7 @@ def test_get_item_not_found(self): client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - pk = 11 + pk = MODEL1_DATA_SIZE + 1 rv = self.auth_client_get( client, token, @@ -387,14 +389,13 @@ def test_get_list(self): token, 'api/v1/model1api/' ) - log.info("DATA !!!!!!!!! {}".format(rv.data)) data = json.loads(rv.data.decode('utf-8')) # Tests count property eq_(data['count'], MODEL1_DATA_SIZE) # Tests data result default page size eq_(len(data['result']), self.model1api.page_size) - for i in range(1, MODEL1_DATA_SIZE): + for i in range(1, self.model1api.page_size): self.assert_get_list(rv, data['result'][i - 1], i - 1) @staticmethod @@ -415,7 +416,7 @@ def test_get_list_order(self): token = self.login(client, USERNAME, PASSWORD) # test string order asc - uri = 'api/v1/model1api/?q=(order_column:field_string,order_direction:asc)' + uri = 'api/v1/model1api/?q=(order_column:field_integer,order_direction:asc)' rv = self.auth_client_get( client, token, @@ -430,7 +431,7 @@ def test_get_list_order(self): }) eq_(rv.status_code, 200) # test string order desc - uri = 'api/v1/model1api/?q=(order_column:field_string,order_direction:desc)' + uri = 'api/v1/model1api/?q=(order_column:field_integer,order_direction:desc)' rv = self.auth_client_get( client, token, @@ -493,6 +494,7 @@ def test_get_list_page(self): # test page zero uri = 'api/v1/model1api/?q=(page_size:{},page:0,order_column:field_integer,order_direction:asc)'.format(page_size) + rv = self.auth_client_get( client, token, @@ -514,7 +516,6 @@ def test_get_list_page(self): token, uri ) - log.info("DATA !!!!! {}".format(rv.data)) data = json.loads(rv.data.decode('utf-8')) eq_(data['result'][0], { @@ -526,6 +527,24 @@ def test_get_list_page(self): eq_(rv.status_code, 200) eq_(len(data['result']), page_size) + def test_get_list_max_page_size(self): + """ + REST Api: Test get list max page size config setting + """ + page_size = 100 # Max is globally set to MAX_PAGE_SIZE + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + + # test page zero + uri = 'api/v1/model1api/?q=(page_size:{},page:0,order_column:field_integer,order_direction:asc)'.format(page_size) + rv = self.auth_client_get( + client, + token, + uri + ) + data = json.loads(rv.data.decode('utf-8')) + eq_(len(data['result']), MAX_PAGE_SIZE) + def test_get_list_filters(self): """ REST Api: Test get list filter params @@ -812,7 +831,7 @@ def test_delete_item_not_found(self): client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - pk = 11 + pk = MODEL1_DATA_SIZE + 1 uri = 'api/v1/model1api/{}'.format(pk) rv = self.auth_client_delete( client, @@ -903,7 +922,7 @@ def test_update_item_not_found(self): """ client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - pk = 11 + pk = MODEL1_DATA_SIZE + 1 item = dict( field_string="test_Put", field_integer=0, @@ -1011,9 +1030,9 @@ def test_create_item(self): client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) item = dict( - field_string="test11", - field_integer=11, - field_float=11.0, + field_string="test{}".format(MODEL1_DATA_SIZE+1), + field_integer=MODEL1_DATA_SIZE+1, + field_float=float(MODEL1_DATA_SIZE+1), field_date=None ) uri = 'api/v1/model1api/' @@ -1026,10 +1045,12 @@ def test_create_item(self): data = json.loads(rv.data.decode('utf-8')) eq_(rv.status_code, 201) eq_(data['result'], item) - model = self.db.session.query(Model1).filter_by(field_string='test11').first() - eq_(model.field_string, "test11") - eq_(model.field_integer, 11) - eq_(model.field_float, 11.0) + model = self.db.session.query(Model1).filter_by( + field_string='test{}'.format(MODEL1_DATA_SIZE+1) + ).first() + eq_(model.field_string, "test{}".format(MODEL1_DATA_SIZE+1)) + eq_(model.field_integer, MODEL1_DATA_SIZE+1) + eq_(model.field_float, float(MODEL1_DATA_SIZE+1)) def test_create_item_val_size(self): """ @@ -1040,8 +1061,8 @@ def test_create_item_val_size(self): field_string = 'a' * 51 item = dict( field_string=field_string, - field_integer=11, - field_float=11.0 + field_integer=MODEL1_DATA_SIZE+1, + field_float=float(MODEL1_DATA_SIZE+1) ) uri = 'api/v1/model1api/' rv = self.auth_client_post( @@ -1061,9 +1082,9 @@ def test_create_item_val_type(self): client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) item = dict( - field_string="test11", - field_integer="test11", - field_float=11.0 + field_string="test{}".format(MODEL1_DATA_SIZE), + field_integer="test{}".format(MODEL1_DATA_SIZE), + field_float=float(MODEL1_DATA_SIZE) ) uri = 'api/v1/model1api/' rv = self.auth_client_post( @@ -1077,9 +1098,9 @@ def test_create_item_val_type(self): eq_(data['message']['field_integer'][0], 'Not a valid integer.') item = dict( - field_string=11, - field_integer=11, - field_float=11.0 + field_string=MODEL1_DATA_SIZE, + field_integer=MODEL1_DATA_SIZE, + field_float=float(MODEL1_DATA_SIZE) ) rv = self.auth_client_post( client, @@ -1120,7 +1141,7 @@ def test_create_item_excluded_cols(self): ) eq_(rv.status_code, 201) model = (self.db.session.query(Model1) - .filter_by(field_string="test11") + .filter_by(field_string="test{}".format(MODEL1_DATA_SIZE+1)) .first()) eq_(model.field_integer, None) eq_(model.field_float, None) @@ -1143,7 +1164,7 @@ def test_get_list_col_function(self): eq_(data['count'], MODEL1_DATA_SIZE) # Tests data result default page size eq_(len(data['result']), self.model1api.page_size) - for i in range(1, MODEL1_DATA_SIZE): + for i in range(1, self.model1api.page_size): item = data['result'][i - 1] eq_( item['full_concat'], "{}.{}.{}.{}".format( From 34e482f2496236dcb1775ebb6dd701eff1c4bc07 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 22 Mar 2019 11:34:38 +0000 Subject: [PATCH 042/109] [api] New, constants to remove hardcoded key names from req and resp --- docs/rest_api.rst | 5 +- flask_appbuilder/api.py | 144 +++++++++++++++++++++++++------------- flask_appbuilder/const.py | 32 ++++++++- 3 files changed, 127 insertions(+), 54 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 992f4238f7..afff3a98a1 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -174,7 +174,6 @@ We can use this package, to help us dump or load python structures to Rison:: "number": 777, "string": "string", "null": None - } print(prison.dumps(b)) @@ -624,7 +623,7 @@ by just defining new methods and protecting them with the ``protect`` decorator: } On all GET HTTP methods we can select which meta data keys we want, this can -be done using *Rison* URI arguments. So the **_info** endpoint is not exception. +be done using *Rison* URI arguments. So the **_info** endpoint is no exception. The across the board way to filter meta data is to send a GET request using the following structure:: @@ -865,7 +864,7 @@ As before meta data can be chosen using *Rison* arguments:: Will only fetch the *label_columns* meta data key -And we can chose which columns to fetch:: +And we can choose which columns to fetch:: (columns:!(name,address)) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 7c62ff2925..007c35b866 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -9,11 +9,32 @@ from .security.decorators import permission_name, protect from marshmallow import ValidationError from ._compat import as_unicode +from .const import ( + API_URI_RIS_KEY, + API_ORDER_COLUMNS_RES_KEY, + API_LABEL_COLUMNS_RES_KEY, + API_LIST_COLUMNS_RES_KEY, + API_DESCRIPTION_COLUMNS_RES_KEY, + API_SHOW_COLUMNS_RES_KEY, + API_ADD_COLUMNS_RES_KEY, + API_EDIT_COLUMNS_RES_KEY, + API_FILTERS_RES_KEY, + API_PERMISSIONS_RES_KEY, + API_RESULT_RES_KEY, + API_ORDER_COLUMNS_RIS_KEY, + API_LABEL_COLUMNS_RIS_KEY, + API_LIST_COLUMNS_RIS_KEY, + API_DESCRIPTION_COLUMNS_RIS_KEY, + API_SHOW_COLUMNS_RIS_KEY, + API_ADD_COLUMNS_RIS_KEY, + API_EDIT_COLUMNS_RIS_KEY, + API_SELECT_COLUMNS_RIS_KEY, + API_FILTERS_RIS_KEY, + API_PERMISSIONS_RIS_KEY +) log = logging.getLogger(__name__) -URI_RISON_KEY = 'q' - def get_error_msg(): """ @@ -56,7 +77,7 @@ def rison_json(self, **kwargs): """ def wraps(self, *args, **kwargs): - value = request.args.get(URI_RISON_KEY, None) + value = request.args.get(API_URI_RIS_KEY, None) kwargs['rison'] = dict() if value: try: @@ -249,7 +270,7 @@ def set_response_key_mappings(self, response, func, rison_args, **kwargs): v(self, response, **kwargs) def merge_current_user_permissions(self, response, **kwargs): - response['permissions'] =\ + response[API_PERMISSIONS_RES_KEY] =\ self.appbuilder.sm.get_user_permissions_on_view( self.__class__.__name__ ) @@ -559,8 +580,11 @@ class ContactModelView(ModelRestApi): def create_blueprint(self, appbuilder, *args, **kwargs): self._init_model_schemas() - return super(ModelRestApi, self).create_blueprint(appbuilder, - *args, **kwargs) + return super(ModelRestApi, self).create_blueprint( + appbuilder, + *args, + **kwargs + ) def _init_model_schemas(self): # Create Marshmalow schemas if one is not specified @@ -646,7 +670,7 @@ def _init_properties(self): self.add_query_rel_fields = self.add_query_rel_fields or dict() def merge_add_field_info(self, response, **kwargs): - response['add_fields'] = \ + response[API_ADD_COLUMNS_RES_KEY] = \ self._get_fields_info( self.add_columns, self.add_model_schema, @@ -654,7 +678,7 @@ def merge_add_field_info(self, response, **kwargs): ) def merge_edit_field_info(self, response, **kwargs): - response['edit_fields'] = \ + response[API_EDIT_COLUMNS_RES_KEY] = \ self._get_fields_info( self.edit_columns, self.edit_model_schema, @@ -670,17 +694,17 @@ def merge_search_filters(self, response, **kwargs): {'name': as_unicode(flt.name), 'operator': flt.arg_name} for flt in dict_filters[col] ] - response['filters'] = search_filters + response[API_FILTERS_RES_KEY] = search_filters @expose('/_info', methods=['GET']) @protect @rison @safe @permission_name('get') - @merge_response_func(BaseApi.merge_current_user_permissions, 'permissions') - @merge_response_func(merge_add_field_info, 'add_fields') - @merge_response_func(merge_edit_field_info, 'edit_fields') - @merge_response_func(merge_search_filters, "filters") + @merge_response_func(BaseApi.merge_current_user_permissions, API_PERMISSIONS_RIS_KEY) + @merge_response_func(merge_add_field_info, API_ADD_COLUMNS_RIS_KEY) + @merge_response_func(merge_edit_field_info, API_EDIT_COLUMNS_RIS_KEY) + @merge_response_func(merge_search_filters, API_FILTERS_RIS_KEY) def info(self, **kwargs): """ Endpoint that renders a response for CRUD REST meta data @@ -720,7 +744,9 @@ def post(self): return self.response( 201, **{ - 'result': self.add_model_schema.dump(item.data, many=False).data, + API_RESULT_RES_KEY: self.add_model_schema.dump( + item.data, many=False + ).data, 'id': self.datamodel.get_pk_value(item.data) } ) @@ -750,7 +776,9 @@ def put(self, pk): self.post_update(item) return self.response( 200, - result=self.edit_model_schema.dump(item.data, many=False).data + **{API_RESULT_RES_KEY: self.edit_model_schema.dump( + item.data, + many=False).data} ) else: return self.response_500() @@ -772,47 +800,47 @@ def delete(self, pk): return self.response_500() def merge_label_columns(self, response, **kwargs): - _pruned_select_cols = kwargs.get('columns', []) + _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) if _pruned_select_cols: _show_columns = _pruned_select_cols else: _show_columns = self.show_columns - response['label_columns'] = self._label_columns_json(_show_columns) + response[API_LABEL_COLUMNS_RES_KEY] = self._label_columns_json(_show_columns) def merge_include_columns(self, response, **kwargs): - _pruned_select_cols = kwargs.get('columns', []) + _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) if _pruned_select_cols: - response['include_columns'] = _pruned_select_cols + response[API_SHOW_COLUMNS_RES_KEY] = _pruned_select_cols else: - response['include_columns'] = self.show_columns + response[API_SHOW_COLUMNS_RES_KEY] = self.show_columns def merge_description_columns(self, response, **kwargs): - _pruned_select_cols = kwargs.get('columns', []) + _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) if _pruned_select_cols: - response['description_columns'] = \ + response[API_DESCRIPTION_COLUMNS_RES_KEY] = \ self._description_columns_json(_pruned_select_cols) else: - response['description_columns'] = \ + response[API_DESCRIPTION_COLUMNS_RES_KEY] = \ self._description_columns_json(self.show_columns) def merge_list_columns(self, response, **kwargs): - _pruned_select_cols = kwargs.get('columns', []) + _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) if _pruned_select_cols: - response['list_columns'] = _pruned_select_cols + response[API_LIST_COLUMNS_RES_KEY] = _pruned_select_cols else: - response['list_columns'] = self.list_columns + response[API_LIST_COLUMNS_RES_KEY] = self.list_columns def merge_order_columns(self, response, **kwargs): - _pruned_select_cols = kwargs.get('columns', []) - response['order_columns'] = [ + _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) + response[API_ORDER_COLUMNS_RES_KEY] = [ order_col for order_col in self.order_columns if order_col in _pruned_select_cols ] @rison - @merge_response_func(merge_label_columns, "label_columns") - @merge_response_func(merge_include_columns, "include_columns") - @merge_response_func(merge_description_columns, "description_columns") + @merge_response_func(merge_label_columns, API_LABEL_COLUMNS_RIS_KEY) + @merge_response_func(merge_include_columns, API_SHOW_COLUMNS_RIS_KEY) + @merge_response_func(merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY) def _get_item(self, pk, **kwargs): item = self.datamodel.get(pk, self._base_filters) if not item: @@ -820,7 +848,7 @@ def _get_item(self, pk, **kwargs): _response = dict() _args = kwargs.get('rison', {}) - select_cols = _args.get('columns', []) + select_cols = _args.get(API_SELECT_COLUMNS_RIS_KEY, []) _pruned_select_cols = [ col for col in select_cols if col in self.show_columns ] @@ -828,7 +856,7 @@ def _get_item(self, pk, **kwargs): _response, self._get_item, _args, - **{"columns": _pruned_select_cols} + **{API_SELECT_COLUMNS_RIS_KEY: _pruned_select_cols} ) if _pruned_select_cols: _show_model_schema = self._model_schema_factory(_pruned_select_cols) @@ -836,25 +864,25 @@ def _get_item(self, pk, **kwargs): _show_model_schema = self.show_model_schema _response['id'] = pk - _response['result'] = _show_model_schema.dump(item, many=False).data + _response[API_RESULT_RES_KEY] = _show_model_schema.dump(item, many=False).data return self.response(200, **_response) @rison - @merge_response_func(merge_order_columns, "order_columns") - @merge_response_func(merge_label_columns, "label_columns") - @merge_response_func(merge_description_columns, "description_columns") - @merge_response_func(merge_list_columns, 'list_columns') + @merge_response_func(merge_order_columns, API_ORDER_COLUMNS_RIS_KEY) + @merge_response_func(merge_label_columns, API_LABEL_COLUMNS_RIS_KEY) + @merge_response_func(merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY) + @merge_response_func(merge_list_columns, API_LIST_COLUMNS_RIS_KEY) def _get_list(self, **kwargs): _response = dict() _args = kwargs.get('rison', {}) # handle select columns - select_cols = _args.get('columns', []) + select_cols = _args.get(API_SELECT_COLUMNS_RIS_KEY, []) _pruned_select_cols = [col for col in select_cols if col in self.list_columns] self.set_response_key_mappings( _response, self._get_list, _args, - **{"columns": _pruned_select_cols} + **{API_SELECT_COLUMNS_RIS_KEY: _pruned_select_cols} ) if _pruned_select_cols: @@ -862,14 +890,9 @@ def _get_list(self, **kwargs): else: _list_model_schema = self.list_model_schema # handle filters - self._filters.clear_filters() - self._filters.rest_add_filters(_args.get('filters', [])) - joined_filters = self._filters.get_joined_filters(self._base_filters) + joined_filters = self._handle_filters_args(_args) # handle base order - order_column = _args.get('order_column', '') - order_direction = _args.get('order_direction', '') - if not order_column and self.base_order: - order_column, order_direction = self.base_order + order_column, order_direction = self._handle_order_args(_args) # handle pagination page_index, page_size = self._handle_page_args(_args) # Make the query @@ -879,7 +902,7 @@ def _get_list(self, **kwargs): page=page_index, page_size=page_size) pks = self.datamodel.get_keys(lst) - _response['result'] = _list_model_schema.dump(lst, many=True).data + _response[API_RESULT_RES_KEY] = _list_model_schema.dump(lst, many=True).data _response['ids'] = pks _response['count'] = count return self.response(200, **_response) @@ -891,8 +914,10 @@ def _get_list(self, **kwargs): """ def _handle_page_args(self, rison_args): """ - Helper function to handle page rison page - argument, sets defaults and impose MAX_PAGE_SIZE + Helper function to handle rison page + arguments, sets defaults and impose + FAB_API_MAX_PAGE_SIZE + :param args: :return: (tuple) page, page_size """ @@ -903,6 +928,25 @@ def _handle_page_args(self, rison_args): page_size = max_page_size return page_index, page_size + def _handle_order_args(self, rison_args): + """ + Help function to handle rison order + arguments + + :param rison_args: + :return: + """ + order_column = rison_args.get('order_column', '') + order_direction = rison_args.get('order_direction', '') + if not order_column and self.base_order: + order_column, order_direction = self.base_order + return order_column, order_direction + + def _handle_filters_args(self, rison_args): + self._filters.clear_filters() + self._filters.rest_add_filters(rison_args.get(API_FILTERS_RIS_KEY, [])) + return self._filters.get_joined_filters(self._base_filters) + def _description_columns_json(self, cols=None): """ Prepares dict with col descriptions to be JSON serializable diff --git a/flask_appbuilder/const.py b/flask_appbuilder/const.py index 7d82d109e4..f2710d314a 100644 --- a/flask_appbuilder/const.py +++ b/flask_appbuilder/const.py @@ -107,7 +107,6 @@ """ Inform that view class was added, format with class name, name""" - FLAMSG_ERR_SEC_ACCESS_DENIED = lazy_gettext("Access is Denied") """ Access denied flash message """ @@ -121,3 +120,34 @@ AUTH_REMOTE_USER = 3 AUTH_OAUTH = 4 """ Constants for supported authentication types """ + +#----------------------------------- +# REST API Constants +#----------------------------------- + +# Response keys + +API_ORDER_COLUMNS_RES_KEY = 'order_columns' +API_LABEL_COLUMNS_RES_KEY = 'label_columns' +API_LIST_COLUMNS_RES_KEY = 'list_columns' +API_SHOW_COLUMNS_RES_KEY = 'include_columns' +API_ADD_COLUMNS_RES_KEY = 'add_fields' +API_EDIT_COLUMNS_RES_KEY = 'edit_fields' +API_DESCRIPTION_COLUMNS_RES_KEY = 'description_columns' +API_RESULT_RES_KEY = 'result' +API_FILTERS_RES_KEY = 'filters' +API_PERMISSIONS_RES_KEY = 'permissions' + +# Request Rison keys + +API_URI_RIS_KEY = 'q' +API_ORDER_COLUMNS_RIS_KEY = 'order_columns' +API_LABEL_COLUMNS_RIS_KEY = 'label_columns' +API_LIST_COLUMNS_RIS_KEY = 'list_columns' +API_SHOW_COLUMNS_RIS_KEY = 'include_columns' +API_ADD_COLUMNS_RIS_KEY = 'add_fields' +API_EDIT_COLUMNS_RIS_KEY = 'edit_fields' +API_DESCRIPTION_COLUMNS_RIS_KEY = 'description_columns' +API_FILTERS_RIS_KEY = 'filters' +API_PERMISSIONS_RIS_KEY = 'permissions' +API_SELECT_COLUMNS_RIS_KEY = 'columns' From 0a28a85a12e452d58c86ffbb1d83690bbfb363f2 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 22 Mar 2019 16:03:44 +0000 Subject: [PATCH 043/109] [api][test] New, constants to remove hardcoded key names --- flask_appbuilder/tests/test_api.py | 200 ++++++++++++++++++++++------- 1 file changed, 152 insertions(+), 48 deletions(-) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 2e1edb73f6..1f39c8b7d7 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -2,11 +2,36 @@ import os import json import logging +import prison from nose.tools import eq_ from flask_appbuilder import SQLA from .sqla.models import Model1, Model2, insert_data from flask_appbuilder.models.sqla.filters import \ FilterGreater, FilterSmaller +from flask_appbuilder.const import ( + API_URI_RIS_KEY, + API_ORDER_COLUMNS_RES_KEY, + API_LABEL_COLUMNS_RES_KEY, + API_LIST_COLUMNS_RES_KEY, + API_DESCRIPTION_COLUMNS_RES_KEY, + API_SHOW_COLUMNS_RES_KEY, + API_ADD_COLUMNS_RES_KEY, + API_EDIT_COLUMNS_RES_KEY, + API_FILTERS_RES_KEY, + API_PERMISSIONS_RES_KEY, + API_RESULT_RES_KEY, + API_ORDER_COLUMNS_RIS_KEY, + API_LABEL_COLUMNS_RIS_KEY, + API_LIST_COLUMNS_RIS_KEY, + API_DESCRIPTION_COLUMNS_RIS_KEY, + API_SHOW_COLUMNS_RIS_KEY, + API_ADD_COLUMNS_RIS_KEY, + API_EDIT_COLUMNS_RIS_KEY, + API_SELECT_COLUMNS_RIS_KEY, + API_FILTERS_RIS_KEY, + API_PERMISSIONS_RIS_KEY +) + log = logging.getLogger(__name__) @@ -260,7 +285,7 @@ def test_get_item(self): self.assert_get_item(rv, data, i - 1) def assert_get_item(self, rv, data, value): - eq_(data['result'], { + eq_(data[API_RESULT_RES_KEY], { 'field_date': None, 'field_float': float(value), 'field_integer': value, @@ -269,7 +294,7 @@ def assert_get_item(self, rv, data, value): # test descriptions eq_(data['description_columns'], self.model1api.description_columns) # test labels - eq_(data['label_columns'], { + eq_(data[API_LABEL_COLUMNS_RES_KEY], { 'field_date': 'Field Date', 'field_float': 'Field Float', 'field_integer': 'Field Integer', @@ -285,18 +310,19 @@ def test_get_item_select_cols(self): token = self.login(client, USERNAME, PASSWORD) for i in range(1, MODEL1_DATA_SIZE): - uri = 'api/v1/model1api/{}?q=(columns:!(field_integer))'.format(i) + uri = ('api/v1/model1api/{}?q=({}:!(field_integer))' + .format(i, API_SELECT_COLUMNS_RIS_KEY)) rv = self.auth_client_get( client, token, uri ) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'], {'field_integer': i - 1}) - eq_(data['description_columns'], { + eq_(data[API_RESULT_RES_KEY], {'field_integer': i - 1}) + eq_(data[API_DESCRIPTION_COLUMNS_RES_KEY], { 'field_integer': 'Field Integer' }) - eq_(data['label_columns'], { + eq_(data[API_LABEL_COLUMNS_RES_KEY], { 'field_integer': 'Field Integer' }) eq_(rv.status_code, 200) @@ -315,7 +341,7 @@ def test_get_item_excluded_cols(self): 'api/v1/model1apiexcludecols/{}'.format(pk) ) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'], { + eq_(data[API_RESULT_RES_KEY], { 'field_string': 'test0' }) eq_(rv.status_code, 200) @@ -375,7 +401,7 @@ def test_get_item_rel_field(self): ) data = json.loads(rv.data.decode('utf-8')) eq_(rv.status_code, 200) - eq_(data['result'], {'group': 1}) + eq_(data[API_RESULT_RES_KEY], {'group': 1}) def test_get_list(self): """ @@ -394,9 +420,9 @@ def test_get_list(self): # Tests count property eq_(data['count'], MODEL1_DATA_SIZE) # Tests data result default page size - eq_(len(data['result']), self.model1api.page_size) + eq_(len(data[API_RESULT_RES_KEY]), self.model1api.page_size) for i in range(1, self.model1api.page_size): - self.assert_get_list(rv, data['result'][i - 1], i - 1) + self.assert_get_list(rv, data[API_RESULT_RES_KEY][i - 1], i - 1) @staticmethod def assert_get_list(rv, data, value): @@ -416,14 +442,21 @@ def test_get_list_order(self): token = self.login(client, USERNAME, PASSWORD) # test string order asc - uri = 'api/v1/model1api/?q=(order_column:field_integer,order_direction:asc)' + arguments = { + "order_column": "field_integer", + "order_direction": "asc" + } + uri = 'api/v1/model1api/?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(arguments) + ) rv = self.auth_client_get( client, token, uri ) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], { + eq_(data[API_RESULT_RES_KEY][0], { 'field_date': None, 'field_float': 0.0, 'field_integer': 0, @@ -431,14 +464,21 @@ def test_get_list_order(self): }) eq_(rv.status_code, 200) # test string order desc - uri = 'api/v1/model1api/?q=(order_column:field_integer,order_direction:desc)' + arguments = { + "order_column": "field_integer", + "order_direction": "desc" + } + uri = 'api/v1/model1api/?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(arguments) + ) rv = self.auth_client_get( client, token, uri ) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], { + eq_(data[API_RESULT_RES_KEY][0], { 'field_date': None, 'field_float': float(MODEL1_DATA_SIZE - 1), 'field_integer': MODEL1_DATA_SIZE - 1, @@ -460,7 +500,7 @@ def test_get_list_base_order(self): 'api/v1/model1apiorder/' ) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], { + eq_(data[API_RESULT_RES_KEY][0], { 'id': MODEL1_DATA_SIZE, 'field_date': None, 'field_float': float(MODEL1_DATA_SIZE - 1), @@ -468,15 +508,21 @@ def test_get_list_base_order(self): 'field_string': "test{}".format(MODEL1_DATA_SIZE - 1) }) # Test override - uri = 'api/v1/model1apiorder/?q_=(order_column:field_integer,order_direction:asc)' + arguments = { + "order_column": "field_integer", + "order_direction": "asc" + } + uri = 'api/v1/model1apiorder/?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(arguments) + ) rv = self.auth_client_get( client, token, - 'api/v1/model1apiorder/?q=(order_column:field_integer,order_direction:asc)' + uri ) - data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], { + eq_(data[API_RESULT_RES_KEY][0], { 'id': 1, 'field_date': None, 'field_float': 0.0, @@ -493,24 +539,41 @@ def test_get_list_page(self): token = self.login(client, USERNAME, PASSWORD) # test page zero - uri = 'api/v1/model1api/?q=(page_size:{},page:0,order_column:field_integer,order_direction:asc)'.format(page_size) - + arguments = { + "page_size": page_size, + "page": 0, + "order_column": "field_integer", + "order_direction": "asc" + } + uri = 'api/v1/model1api/?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(arguments) + ) rv = self.auth_client_get( client, token, uri ) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], { + eq_(data[API_RESULT_RES_KEY][0], { 'field_date': None, 'field_float': 0.0, 'field_integer': 0, 'field_string': "test0" }) eq_(rv.status_code, 200) - eq_(len(data['result']), page_size) - # test page zero - uri = 'api/v1/model1api/?q=(page_size:{},page:1,order_column:field_integer,order_direction:asc)'.format(page_size) + eq_(len(data[API_RESULT_RES_KEY]), page_size) + # test page one + arguments = { + "page_size": page_size, + "page": 1, + "order_column": "field_integer", + "order_direction": "asc" + } + uri = 'api/v1/model1api/?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(arguments) + ) rv = self.auth_client_get( client, token, @@ -518,14 +581,14 @@ def test_get_list_page(self): ) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], { + eq_(data[API_RESULT_RES_KEY][0], { 'field_date': None, 'field_float': float(page_size), 'field_integer': page_size, 'field_string': "test{}".format(page_size) }) eq_(rv.status_code, 200) - eq_(len(data['result']), page_size) + eq_(len(data[API_RESULT_RES_KEY]), page_size) def test_get_list_max_page_size(self): """ @@ -536,14 +599,23 @@ def test_get_list_max_page_size(self): token = self.login(client, USERNAME, PASSWORD) # test page zero - uri = 'api/v1/model1api/?q=(page_size:{},page:0,order_column:field_integer,order_direction:asc)'.format(page_size) + arguments = { + "page_size": page_size, + "page": 0, + "order_column": "field_integer", + "order_direction": "asc" + } + uri = 'api/v1/model1api/?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(arguments) + ) rv = self.auth_client_get( client, token, uri ) data = json.loads(rv.data.decode('utf-8')) - eq_(len(data['result']), MAX_PAGE_SIZE) + eq_(len(data[API_RESULT_RES_KEY]), MAX_PAGE_SIZE) def test_get_list_filters(self): """ @@ -554,7 +626,21 @@ def test_get_list_filters(self): filter_value = 5 # test string order asc - uri = 'api/v1/model1api/?q=(filters:!((col:field_integer,opr:gt,value:{})),order_columns:field_integer,order_direction:asc)'.format(filter_value) + arguments = { + API_FILTERS_RIS_KEY: [ + { + "col": "field_integer", + "opr": "gt", + "value": filter_value + } + ], + "order_column": "field_integer", + "order_direction": "asc" + } + + uri = 'api/v1/model1api/?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(arguments)) rv = self.auth_client_get( client, @@ -562,7 +648,7 @@ def test_get_list_filters(self): uri ) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], { + eq_(data[API_RESULT_RES_KEY][0], { 'field_date': None, 'field_float': float(filter_value + 1), 'field_integer': filter_value + 1, @@ -577,23 +663,34 @@ def test_get_list_select_cols(self): client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - uri = 'api/v1/model1api/?q=(columns:!(field_integer),order_column:field_integer,order_direction:asc)' + argument = { + API_SELECT_COLUMNS_RIS_KEY: [ + "field_integer" + ], + "order_column": "field_integer", + "order_direction": "asc" + } + + uri = 'api/v1/model1api/?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(argument) + ) rv = self.auth_client_get( client, token, uri ) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], { + eq_(data[API_RESULT_RES_KEY][0], { 'field_integer': 0, }) - eq_(data['label_columns'], { + eq_(data[API_LABEL_COLUMNS_RES_KEY], { 'field_integer': 'Field Integer' }) - eq_(data['description_columns'], { + eq_(data[API_DESCRIPTION_COLUMNS_RES_KEY], { 'field_integer': 'Field Integer' }) - eq_(data['list_columns'], [ + eq_(data[API_LIST_COLUMNS_RES_KEY], [ 'field_integer' ]) eq_(rv.status_code, 200) @@ -612,7 +709,7 @@ def test_get_list_exclude_cols(self): uri ) data = json.loads(rv.data.decode('utf-8')) - eq_(data['result'][0], { + eq_(data[API_RESULT_RES_KEY][0], { 'id': 1, 'field_string': 'test0' }) @@ -624,7 +721,14 @@ def test_get_list_base_filters(self): client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - uri = 'api/v1/model1apifiltered/?order_columns:field_integer,order_direction:asc' + arguments = { + "order_column": "field_integer", + "order_direction": "desc" + } + uri = 'api/v1/model1apifiltered/?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(arguments) + ) rv = self.auth_client_get( client, token, @@ -640,7 +744,7 @@ def test_get_list_base_filters(self): 'id': 4 } ] - eq_(data['result'], expected_result) + eq_(data[API_RESULT_RES_KEY], expected_result) def test_info_filters(self): """ @@ -736,8 +840,8 @@ def test_info_fields(self): for item in expect_add_fields: if item['name'] == edit_col: expect_edit_fields.append(item) - eq_(data['add_fields'], expect_add_fields) - eq_(data['edit_fields'], expect_edit_fields) + eq_(data[API_ADD_COLUMNS_RES_KEY], expect_add_fields) + eq_(data[API_EDIT_COLUMNS_RES_KEY], expect_edit_fields) def test_info_fields_rel_field(self): """ @@ -799,10 +903,10 @@ def test_info_fields_rel_filtered_field(self): } ] } - for rel_field in data['add_fields']: + for rel_field in data[API_ADD_COLUMNS_RES_KEY]: if rel_field['name'] == 'group': eq_(rel_field, expected_rel_add_field) - for rel_field in data['edit_fields']: + for rel_field in data[API_EDIT_COLUMNS_RES_KEY]: if rel_field['name'] == 'group': eq_(rel_field, expected_rel_add_field) @@ -969,8 +1073,8 @@ def test_update_item_val_type(self): token = self.login(client, USERNAME, PASSWORD) pk = 1 item = dict( - field_string="test11", - field_integer="test11", + field_string="test{}".format(MODEL1_DATA_SIZE+1), + field_integer="test{}".format(MODEL1_DATA_SIZE+1), field_float=11.0 ) uri = 'api/v1/model1api/{}'.format(pk) @@ -1044,7 +1148,7 @@ def test_create_item(self): ) data = json.loads(rv.data.decode('utf-8')) eq_(rv.status_code, 201) - eq_(data['result'], item) + eq_(data[API_RESULT_RES_KEY], item) model = self.db.session.query(Model1).filter_by( field_string='test{}'.format(MODEL1_DATA_SIZE+1) ).first() @@ -1163,9 +1267,9 @@ def test_get_list_col_function(self): # Tests count property eq_(data['count'], MODEL1_DATA_SIZE) # Tests data result default page size - eq_(len(data['result']), self.model1api.page_size) + eq_(len(data[API_RESULT_RES_KEY]), self.model1api.page_size) for i in range(1, self.model1api.page_size): - item = data['result'][i - 1] + item = data[API_RESULT_RES_KEY][i - 1] eq_( item['full_concat'], "{}.{}.{}.{}".format( "test" + str(i - 1), From 3dfbbb5206e72de28c7f1a9b3d0bd75714e41927 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 22 Mar 2019 18:13:02 +0000 Subject: [PATCH 044/109] [api][tests] New, select metadata for get methods --- docs/rest_api.rst | 4 +- flask_appbuilder/const.py | 1 + flask_appbuilder/tests/test_api.py | 100 +++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index afff3a98a1..54f073868d 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -1,7 +1,7 @@ -REST Api +REST API ======== -On this chapter we are going to describe how you can define a RESTfull API +On this chapter we are going to describe how you can define a RESTful API using almost the same concept as defining your MVC views. :note: diff --git a/flask_appbuilder/const.py b/flask_appbuilder/const.py index f2710d314a..32c680c72a 100644 --- a/flask_appbuilder/const.py +++ b/flask_appbuilder/const.py @@ -151,3 +151,4 @@ API_FILTERS_RIS_KEY = 'filters' API_PERMISSIONS_RIS_KEY = 'permissions' API_SELECT_COLUMNS_RIS_KEY = 'columns' +API_SELECT_KEYS_RIS_KEY = 'keys' diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 1f39c8b7d7..8c24b5cf12 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -28,6 +28,7 @@ API_ADD_COLUMNS_RIS_KEY, API_EDIT_COLUMNS_RIS_KEY, API_SELECT_COLUMNS_RIS_KEY, + API_SELECT_KEYS_RIS_KEY, API_FILTERS_RIS_KEY, API_PERMISSIONS_RIS_KEY ) @@ -327,6 +328,38 @@ def test_get_item_select_cols(self): }) eq_(rv.status_code, 200) + def test_get_item_select_meta_data(self): + """ + REST Api: Test get item select meta data + """ + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + + selectable_keys = [ + API_DESCRIPTION_COLUMNS_RIS_KEY, + API_LABEL_COLUMNS_RIS_KEY, + API_SHOW_COLUMNS_RIS_KEY + ] + for selectable_key in selectable_keys: + argument = { + API_SELECT_KEYS_RIS_KEY: [ + selectable_key + ] + } + uri = 'api/v1/model1api/1?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(argument) + ) + rv = self.auth_client_get( + client, + token, + uri + ) + data = json.loads(rv.data.decode('utf-8')) + eq_(len(data.keys()), 1 + 2) # always exist id, result + # We assume that rison meta key equals result meta key + assert selectable_key in data + def test_get_item_excluded_cols(self): """ REST Api: Test get item with excluded columns @@ -695,6 +728,39 @@ def test_get_list_select_cols(self): ]) eq_(rv.status_code, 200) + def test_get_list_select_meta_data(self): + """ + REST Api: Test get list select meta data + """ + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + + selectable_keys = [ + API_DESCRIPTION_COLUMNS_RIS_KEY, + API_LABEL_COLUMNS_RIS_KEY, + API_ORDER_COLUMNS_RIS_KEY, + API_LIST_COLUMNS_RIS_KEY + ] + for selectable_key in selectable_keys: + argument = { + API_SELECT_KEYS_RIS_KEY: [ + selectable_key + ] + } + uri = 'api/v1/model1api/?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(argument) + ) + rv = self.auth_client_get( + client, + token, + uri + ) + data = json.loads(rv.data.decode('utf-8')) + eq_(len(data.keys()), 1 + 3) # always exist count, ids, result + # We assume that rison meta key equals result meta key + assert selectable_key in data + def test_get_list_exclude_cols(self): """ REST Api: Test get list with excluded columns @@ -910,6 +976,40 @@ def test_info_fields_rel_filtered_field(self): if rel_field['name'] == 'group': eq_(rel_field, expected_rel_add_field) + def test_info_select_meta_data(self): + """ + REST Api: Test info select meta data + """ + # select meta for add fields + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + + selectable_keys = [ + API_ADD_COLUMNS_RIS_KEY, + API_EDIT_COLUMNS_RIS_KEY, + API_PERMISSIONS_RIS_KEY, + API_FILTERS_RIS_KEY + ] + for selectable_key in selectable_keys: + arguments = { + API_SELECT_KEYS_RIS_KEY: [ + selectable_key + ] + } + uri = 'api/v1/model1api/_info?{}={}'.format( + API_URI_RIS_KEY, + prison.dumps(arguments) + ) + rv = self.auth_client_get( + client, + token, + uri + ) + data = json.loads(rv.data.decode('utf-8')) + eq_(len(data.keys()), 1) + # We assume that rison meta key equals result meta key + assert selectable_key in data + def test_delete_item(self): """ REST Api: Test delete item From b7a39f4427f8de8e2d53cd3fa5ae2673a9b57b0b Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 22 Mar 2019 18:16:57 +0000 Subject: [PATCH 045/109] Fix, Removed unnecessary file --- NOTE.txt | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 NOTE.txt diff --git a/NOTE.txt b/NOTE.txt deleted file mode 100644 index 1d6e11e909..0000000000 --- a/NOTE.txt +++ /dev/null @@ -1,11 +0,0 @@ -# LOGIN_DB -# LDAP -# REMOTE USER ( is this possible secure? ) -# OAUTH ( is this done on the backend? ) -# AUTH0 ( trusted JWT we need a key to trust ) - -# Study asymmetric crypto option, good for AUTH0 -# Study refresh tokens -# https://flask-jwt-extended.readthedocs.io/en/latest/refresh_tokens.html#refresh-tokens -# https://tools.ietf.org/html/rfc7519 -# https://auth0.com/blog/json-web-token-signing-algorithms-overview/ From cbda82bd3dc7bbb524053542dce49260f891d0bd Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 22 Mar 2019 19:38:52 +0000 Subject: [PATCH 046/109] [style] Fix, remove print --- flask_appbuilder/models/filters.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flask_appbuilder/models/filters.py b/flask_appbuilder/models/filters.py index 2186882618..728f36cbdf 100644 --- a/flask_appbuilder/models/filters.py +++ b/flask_appbuilder/models/filters.py @@ -30,7 +30,6 @@ class BaseFilter(object): REST API use """ - def __init__(self, column_name, datamodel, is_related_view=False): """ Constructor. @@ -160,7 +159,6 @@ def rest_add_filters(self, data): """ for _filter in data: filter_class = map_args_filter.get(_filter['opr'], None) - print("OPR {}".format(filter_class)) if filter_class: self.add_filter(_filter['col'], filter_class, _filter['value']) From dee6d8114bb6db594c617de58c1ea2c37e875e19 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 25 Mar 2019 11:25:22 +0000 Subject: [PATCH 047/109] [api] Fix, user current user from JWT and not from flask-login --- flask_appbuilder/base.py | 4 +-- flask_appbuilder/security/decorators.py | 20 +++++++---- flask_appbuilder/security/manager.py | 10 ++++++ flask_appbuilder/security/sqla/models.py | 45 +++++++++++++++--------- 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index 35014942f6..4f104f3d41 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -2,7 +2,6 @@ from flask import Blueprint, url_for, current_app from flask_marshmallow import Marshmallow -from flask_jwt_extended import JWTManager from .views import IndexView, UtilView from .filters import TemplateFilters from .menu import Menu @@ -136,7 +135,6 @@ def __init__(self, app=None, self.app = app self.marshmallow = Marshmallow() - self.jwt_manager = JWTManager() if app is not None: self.init_app(app, session) @@ -169,7 +167,6 @@ def init_app(self, app, session): self._add_addon_views() self._add_menu_permissions() self.marshmallow.init_app(self.app) - self.jwt_manager.init_app(self.app) if not self.app: for baseview in self.baseviews: # instantiate the views and add session @@ -182,6 +179,7 @@ def init_app(self, app, session): self._init_extension(app) def _init_extension(self, app): + app.appbuilder = self if not hasattr(app, 'extensions'): app.extensions = {} app.extensions['appbuilder'] = self diff --git a/flask_appbuilder/security/decorators.py b/flask_appbuilder/security/decorators.py index c8492d25b5..8533d5afc2 100644 --- a/flask_appbuilder/security/decorators.py +++ b/flask_appbuilder/security/decorators.py @@ -1,9 +1,16 @@ import logging import functools -from flask import flash, redirect, url_for, make_response, jsonify, request -from flask_jwt_extended import verify_jwt_in_request, get_jwt_identity -from flask_login import login_user +from flask import ( + flash, + redirect, + url_for, + make_response, + jsonify, + request, + current_app +) +from flask_jwt_extended import verify_jwt_in_request from .._compat import as_unicode from ..const import LOGMSG_ERR_SEC_ACCESS_DENIED, FLAMSG_ERR_SEC_ACCESS_DENIED, PERMISSION_PREFIX @@ -25,14 +32,13 @@ def protect(f): def wraps(self, *args, **kwargs): permission_str = "{}{}".format(PERMISSION_PREFIX, f._permission_name) - if self.appbuilder.sm.is_item_public( + if current_app.appbuilder.sm.is_item_public( permission_str, self.__class__.__name__ ): return f(self, *args, **kwargs) verify_jwt_in_request() - login_user(self.appbuilder.sm.get_user_by_id(int(get_jwt_identity()))) - if self.appbuilder.sm.has_access( + if current_app.appbuilder.sm.has_access( permission_str, self.__class__.__name__ ): @@ -44,7 +50,7 @@ def wraps(self, *args, **kwargs): self.__class__.__name__ ) ) - return self.response_401() + return self.response_401() f._permission_name = permission_str return functools.update_wrapper(wraps, f) diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index 856690ec5c..5d8543af31 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -6,6 +6,8 @@ from flask import url_for, g, session from werkzeug.security import generate_password_hash, check_password_hash from flask_login import LoginManager, current_user +from flask_jwt_extended import JWTManager +from flask_jwt_extended import current_user as current_user_jwt from flask_openid import OpenID from flask_babel import lazy_gettext as _ from .api import ( @@ -253,10 +255,16 @@ def __init__(self, appbuilder): self.oauth_whitelists[provider_name] = _provider['whitelist'] self.oauth_remotes[provider_name] = obj_provider + # Setup Flask-Login self.lm = LoginManager(app) self.lm.login_view = 'login' self.lm.user_loader(self.load_user) + # Setup Flask-Jwt-Extended + self.jwt_manager = JWTManager() + self.jwt_manager.init_app(app) + self.jwt_manager.user_loader_callback_loader(self.load_user) + @property def get_url_for_registeruser(self): return url_for( @@ -991,6 +999,8 @@ def has_access(self, permission_name, view_name): """ if current_user.is_authenticated: return self._has_view_access(g.user, permission_name, view_name) + elif current_user_jwt: + return self._has_view_access(current_user_jwt, permission_name, view_name) else: return self.is_item_public(permission_name, view_name) diff --git a/flask_appbuilder/security/sqla/models.py b/flask_appbuilder/security/sqla/models.py index 09a8f3da93..e85741a9f3 100644 --- a/flask_appbuilder/security/sqla/models.py +++ b/flask_appbuilder/security/sqla/models.py @@ -1,6 +1,7 @@ import datetime from flask import g -from sqlalchemy import Table, Column, Integer, String, Boolean, DateTime, ForeignKey, Sequence, UniqueConstraint +from sqlalchemy import Table, Column, Integer, \ + String, Boolean, DateTime, ForeignKey, Sequence, UniqueConstraint from sqlalchemy.orm import relationship, backref from sqlalchemy.ext.declarative import declared_attr from ... import Model @@ -46,11 +47,12 @@ def __repr__(self): return str(self.permission).replace('_', ' ') + ' on ' + str(self.view_menu) -assoc_permissionview_role = Table('ab_permission_view_role', Model.metadata, - Column('id', Integer, Sequence('ab_permission_view_role_id_seq'), primary_key=True), - Column('permission_view_id', Integer, ForeignKey('ab_permission_view.id')), - Column('role_id', Integer, ForeignKey('ab_role.id')), - UniqueConstraint('permission_view_id', 'role_id') +assoc_permissionview_role = Table( + 'ab_permission_view_role', Model.metadata, + Column('id', Integer, Sequence('ab_permission_view_role_id_seq'), primary_key=True), + Column('permission_view_id', Integer, ForeignKey('ab_permission_view.id')), + Column('role_id', Integer, ForeignKey('ab_role.id')), + UniqueConstraint('permission_view_id', 'role_id') ) @@ -59,17 +61,22 @@ class Role(Model): id = Column(Integer, Sequence('ab_role_id_seq'), primary_key=True) name = Column(String(64), unique=True, nullable=False) - permissions = relationship('PermissionView', secondary=assoc_permissionview_role, backref='role') + permissions = relationship( + 'PermissionView', + secondary=assoc_permissionview_role, + backref='role' + ) def __repr__(self): return self.name -assoc_user_role = Table('ab_user_role', Model.metadata, - Column('id', Integer, Sequence('ab_user_role_id_seq'), primary_key=True), - Column('user_id', Integer, ForeignKey('ab_user.id')), - Column('role_id', Integer, ForeignKey('ab_role.id')), - UniqueConstraint('user_id', 'role_id') +assoc_user_role = Table( + 'ab_user_role', Model.metadata, + Column('id', Integer, Sequence('ab_user_role_id_seq'), primary_key=True), + Column('user_id', Integer, ForeignKey('ab_user.id')), + Column('role_id', Integer, ForeignKey('ab_role.id')), + UniqueConstraint('user_id', 'role_id') ) @@ -99,10 +106,16 @@ def changed_by_fk(self): return Column(Integer, ForeignKey('ab_user.id'), default=self.get_user_id, nullable=True) - created_by = relationship("User", backref=backref("created", uselist=True), - remote_side=[id], primaryjoin='User.created_by_fk == User.id', uselist=False) - changed_by = relationship("User", backref=backref("changed", uselist=True), - remote_side=[id], primaryjoin='User.changed_by_fk == User.id', uselist=False) + created_by = relationship( + "User", + backref=backref("created", uselist=True), + remote_side=[id], primaryjoin='User.created_by_fk == User.id', uselist=False + ) + changed_by = relationship( + "User", + backref=backref("changed", uselist=True), + remote_side=[id], primaryjoin='User.changed_by_fk == User.id', uselist=False + ) @classmethod def get_user_id(cls): From 8aeb40b262076fece18a575870e6e56509a85e74 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 25 Mar 2019 13:07:17 +0000 Subject: [PATCH 048/109] [api] New, optional refresh token and configurable JWT user load --- docs/rest_api.rst | 17 +++++++++++++++- flask_appbuilder/security/api.py | 34 ++++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 54f073868d..bbcd3c2595 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -288,7 +288,11 @@ For this we POST request with a JSON payload using:: } Notice the *provider* argument, FAB currently supports DB and LDAP -authentication backends for the Api. +authentication backends for the Api. The login endpoint returns a fresh **access token** and optionally +a **refresh token**. You can renew the **access token** using the **refresh token** but this time +the returned token will not be fresh. To obtain a new non fresh access token +use ``refresh`` endpoint with the **refresh token**. To obtain a **refresh token** on the login endpoint +send the optional parameter **"refresh": true** on the JSON PUT payload. Let's request our Token then:: @@ -336,6 +340,17 @@ methods:: base_permissions = ['can_private'] +You can create an alternate JWT user loader, this can be useful if you want +to use an external Authentication provider and map the JWT identity to your +user Model:: + + @appbuilder.sm.jwt_manager.user_loader_callback_loader + def alternate_user_loader(identity): + # find the user by it's identity + ... + return user + + Model REST Api -------------- diff --git a/flask_appbuilder/security/api.py b/flask_appbuilder/security/api.py index 2c9daa1144..a8ef59888a 100644 --- a/flask_appbuilder/security/api.py +++ b/flask_appbuilder/security/api.py @@ -1,8 +1,9 @@ from flask import request -from flask_jwt_extended import create_access_token +from flask_jwt_extended import create_access_token, create_refresh_token, \ + jwt_refresh_token_required, get_jwt_identity from flask_babel import lazy_gettext from ..views import expose -from ..api import BaseApi, ModelRestApi +from ..api import BaseApi, ModelRestApi, safe class SecurityApi(BaseApi): @@ -10,16 +11,19 @@ class SecurityApi(BaseApi): route_base = '/api/v1/security' @expose('/login', methods=['POST']) + @safe def login(self): """ - Login endpoint for the API returns a JWT - :return: + Login endpoint for the API returns a JWT and possibly a refresh token + :return: Flask response with JSON payload containing an + access_token and refresh_token """ if not request.is_json: return self.response_400(message="Request payload is not JSON") username = request.json.get('username', None) password = request.json.get('password', None) provider = request.json.get('provider', None) + refresh = request.json.get('refresh', False) if not username or not password or not provider: return self.response_400(message="Missing required parameter") # AUTH @@ -41,8 +45,26 @@ def login(self): return self.response_401() # Identity can be any data that is json serializable - access_token = create_access_token(identity=user.id) - return self.response(200, access_token=access_token) + resp = dict() + resp['access_token'] = create_access_token(identity=user.id, fresh=True) + if refresh: + resp['refresh_token'] = create_refresh_token(identity=user.id) + return self.response(200, **resp) + + @expose('/refresh', methods=['POST']) + @jwt_refresh_token_required + @safe + def refresh(self): + """ + Security endpoint for the refresh token, so we can obtain a new + token without forcing the user to login again + :return: Flask Response with JSON payload containing + a new access_token + """ + return self.response( + 200, + access_token=create_access_token(identity=get_jwt_identity(), fresh=False) + ) class UserApi(ModelRestApi): From 7cb7e877ce0333b41e99c4f41b2635af715352b1 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 25 Mar 2019 15:22:18 +0000 Subject: [PATCH 049/109] [api] Use constants on login and refresh endpoint --- flask_appbuilder/const.py | 9 +++++++ flask_appbuilder/security/api.py | 42 +++++++++++++++++++++--------- flask_appbuilder/tests/test_api.py | 24 +++++++++++------ 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/flask_appbuilder/const.py b/flask_appbuilder/const.py index 32c680c72a..88550b1411 100644 --- a/flask_appbuilder/const.py +++ b/flask_appbuilder/const.py @@ -125,6 +125,15 @@ # REST API Constants #----------------------------------- +API_SECURITY_VERSION = 'v1' +API_SECURITY_PROVIDER_DB = 'db' +API_SECURITY_PROVIDER_LDAP = 'ldap' +API_SECURITY_USERNAME_KEY = 'username' +API_SECURITY_PASSWORD_KEY = 'password' +API_SECURITY_PROVIDER_KEY = 'provider' +API_SECURITY_REFRESH_KEY = 'refresh' +API_SECURITY_ACCESS_TOKEN_KEY = 'access_token' +API_SECURITY_REFRESH_TOKEN_KEY = 'refresh_token' # Response keys API_ORDER_COLUMNS_RES_KEY = 'order_columns' diff --git a/flask_appbuilder/security/api.py b/flask_appbuilder/security/api.py index a8ef59888a..ec0f7f76ab 100644 --- a/flask_appbuilder/security/api.py +++ b/flask_appbuilder/security/api.py @@ -2,13 +2,25 @@ from flask_jwt_extended import create_access_token, create_refresh_token, \ jwt_refresh_token_required, get_jwt_identity from flask_babel import lazy_gettext +from ..const import ( + API_SECURITY_VERSION, + API_SECURITY_PROVIDER_DB, + API_SECURITY_PROVIDER_LDAP, + API_SECURITY_USERNAME_KEY, + API_SECURITY_PASSWORD_KEY, + API_SECURITY_PROVIDER_KEY, + API_SECURITY_REFRESH_KEY, + API_SECURITY_ACCESS_TOKEN_KEY, + API_SECURITY_REFRESH_TOKEN_KEY +) from ..views import expose from ..api import BaseApi, ModelRestApi, safe class SecurityApi(BaseApi): - route_base = '/api/v1/security' + resource_name = 'security' + version = API_SECURITY_VERSION @expose('/login', methods=['POST']) @safe @@ -20,19 +32,19 @@ def login(self): """ if not request.is_json: return self.response_400(message="Request payload is not JSON") - username = request.json.get('username', None) - password = request.json.get('password', None) - provider = request.json.get('provider', None) - refresh = request.json.get('refresh', False) + username = request.json.get(API_SECURITY_USERNAME_KEY, None) + password = request.json.get(API_SECURITY_PASSWORD_KEY, None) + provider = request.json.get(API_SECURITY_PROVIDER_KEY, None) + refresh = request.json.get(API_SECURITY_REFRESH_KEY, False) if not username or not password or not provider: return self.response_400(message="Missing required parameter") # AUTH - if provider == 'db': + if provider == API_SECURITY_PROVIDER_DB: user = self.appbuilder.sm.auth_user_db( username, password ) - elif provider == 'ldap': + elif provider == API_SECURITY_PROVIDER_LDAP: user = self.appbuilder.sm.auth_user_ldap( username, password @@ -46,9 +58,11 @@ def login(self): # Identity can be any data that is json serializable resp = dict() - resp['access_token'] = create_access_token(identity=user.id, fresh=True) + resp[API_SECURITY_ACCESS_TOKEN_KEY] = \ + create_access_token(identity=user.id, fresh=True) if refresh: - resp['refresh_token'] = create_refresh_token(identity=user.id) + resp[API_SECURITY_REFRESH_TOKEN_KEY] = \ + create_refresh_token(identity=user.id) return self.response(200, **resp) @expose('/refresh', methods=['POST']) @@ -61,10 +75,12 @@ def refresh(self): :return: Flask Response with JSON payload containing a new access_token """ - return self.response( - 200, - access_token=create_access_token(identity=get_jwt_identity(), fresh=False) - ) + resp = { + API_SECURITY_REFRESH_TOKEN_KEY: create_access_token( + identity=get_jwt_identity(), fresh=False + ) + } + return self.response(200, **resp) class UserApi(ModelRestApi): diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 8c24b5cf12..0ba6206f1c 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -30,7 +30,13 @@ API_SELECT_COLUMNS_RIS_KEY, API_SELECT_KEYS_RIS_KEY, API_FILTERS_RIS_KEY, - API_PERMISSIONS_RIS_KEY + API_PERMISSIONS_RIS_KEY, + API_SECURITY_USERNAME_KEY, + API_SECURITY_PASSWORD_KEY, + API_SECURITY_PROVIDER_KEY, + API_SECURITY_ACCESS_TOKEN_KEY, + API_SECURITY_REFRESH_TOKEN_KEY, + API_SECURITY_VERSION ) @@ -221,12 +227,14 @@ def _login(client, username, password): :return: Flask client response class """ return client.post( - 'api/v1/security/login', - data=json.dumps(dict( - username=username, - password=password, - provider="db" - )), + 'api/{}/security/login'.format(API_SECURITY_VERSION), + data=json.dumps( + { + API_SECURITY_USERNAME_KEY: username, + API_SECURITY_PASSWORD_KEY: password, + API_SECURITY_PROVIDER_KEY: "db" + } + ), content_type='application/json' ) @@ -247,7 +255,7 @@ def test_auth_login(self): eq_(rv.status_code, 200) assert json.loads( rv.data.decode('utf-8') - ).get("access_token", False) + ).get(API_SECURITY_ACCESS_TOKEN_KEY, False) def test_auth_login_failed(self): """ From d0a2e8366e8234a78b2f832d7c39c005024a41a9 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 25 Mar 2019 15:26:25 +0000 Subject: [PATCH 050/109] [api] Fix, updated requirements.txt from pip-compile --- requirements.txt | 69 ++++++++++++++++++++++-------------------------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/requirements.txt b/requirements.txt index 51a98f3fef..bbfd2a3adb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,42 +1,37 @@ -Babel==2.6.0 -certifi==2019.3.9 -chardet==3.0.4 -Click==7.0 +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile --output-file requirements.txt setup.py +# +babel==2.6.0 # via flask-babel +certifi==2019.3.9 # via requests +chardet==3.0.4 # via requests +click==7.0 colorama==0.4.1 -coverage==4.5.3 -coveralls==1.7.0 -defusedxml==0.5.0 -docopt==0.6.2 -Flask==1.0.2 -Flask-Babel==0.12.2 -Flask-JWT-Extended==3.18.0 -Flask-Login==0.4.1 +defusedxml==0.5.0 # via python3-openid +flask-babel==0.12.2 +flask-jwt-extended==3.18.0 +flask-login==0.4.1 flask-marshmallow==0.9.0 -flask-mongoengine==0.7.1 -Flask-OpenID==1.2.5 -Flask-SQLAlchemy==2.3.2 -Flask-WTF==0.14.2 -funcparserlib==0.3.6 -idna==2.8 -itsdangerous==1.1.0 -Jinja2==2.10 -MarkupSafe==1.1.1 -marshmallow==2.18.0 +flask-openid==1.2.5 +flask-sqlalchemy==2.3.2 +flask-wtf==0.14.2 +flask==1.0.2 +idna==2.8 # via requests +itsdangerous==1.1.0 # via flask +jinja2==2.10 # via flask, flask-babel +markupsafe==1.1.1 # via jinja2 marshmallow-sqlalchemy==0.15.0 -mockldap==0.3.0 -mongoengine==0.7.10 -nose==1.3.7 -Pillow==3.4.2 +marshmallow==2.18.0 prison==0.1.0 -pyasn1==0.4.5 -pyasn1-modules==0.2.4 -PyJWT==1.7.1 -pymongo==2.8.1 +pyjwt==1.7.1 python-dateutil==2.8.0 -pytz==2018.9 -requests==2.21.0 -six==1.12.0 -SQLAlchemy==1.3.1 -Werkzeug==0.14.1 -WTForms==2.2.1 - +python3-openid==3.1.0 # via flask-openid +pytz==2018.9 # via babel +requests==2.21.0 # via prison +six==1.12.0 # via flask-jwt-extended, flask-marshmallow, prison, python-dateutil +sqlalchemy==1.3.1 # via flask-sqlalchemy, marshmallow-sqlalchemy +urllib3==1.24.1 # via requests +werkzeug==0.14.1 # via flask, flask-jwt-extended +wtforms==2.2.1 # via flask-wtf From 726501ad43c1933e9ed6d47f01112f7bb6b66a7e Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 25 Mar 2019 15:37:14 +0000 Subject: [PATCH 051/109] Revert "[api] Fix, updated requirements.txt from pip-compile" This reverts commit d0a2e8366e8234a78b2f832d7c39c005024a41a9. Fails on python 2.7 not ready yet --- requirements.txt | 69 ++++++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/requirements.txt b/requirements.txt index bbfd2a3adb..51a98f3fef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,37 +1,42 @@ -# -# This file is autogenerated by pip-compile -# To update, run: -# -# pip-compile --output-file requirements.txt setup.py -# -babel==2.6.0 # via flask-babel -certifi==2019.3.9 # via requests -chardet==3.0.4 # via requests -click==7.0 +Babel==2.6.0 +certifi==2019.3.9 +chardet==3.0.4 +Click==7.0 colorama==0.4.1 -defusedxml==0.5.0 # via python3-openid -flask-babel==0.12.2 -flask-jwt-extended==3.18.0 -flask-login==0.4.1 +coverage==4.5.3 +coveralls==1.7.0 +defusedxml==0.5.0 +docopt==0.6.2 +Flask==1.0.2 +Flask-Babel==0.12.2 +Flask-JWT-Extended==3.18.0 +Flask-Login==0.4.1 flask-marshmallow==0.9.0 -flask-openid==1.2.5 -flask-sqlalchemy==2.3.2 -flask-wtf==0.14.2 -flask==1.0.2 -idna==2.8 # via requests -itsdangerous==1.1.0 # via flask -jinja2==2.10 # via flask, flask-babel -markupsafe==1.1.1 # via jinja2 -marshmallow-sqlalchemy==0.15.0 +flask-mongoengine==0.7.1 +Flask-OpenID==1.2.5 +Flask-SQLAlchemy==2.3.2 +Flask-WTF==0.14.2 +funcparserlib==0.3.6 +idna==2.8 +itsdangerous==1.1.0 +Jinja2==2.10 +MarkupSafe==1.1.1 marshmallow==2.18.0 +marshmallow-sqlalchemy==0.15.0 +mockldap==0.3.0 +mongoengine==0.7.10 +nose==1.3.7 +Pillow==3.4.2 prison==0.1.0 -pyjwt==1.7.1 +pyasn1==0.4.5 +pyasn1-modules==0.2.4 +PyJWT==1.7.1 +pymongo==2.8.1 python-dateutil==2.8.0 -python3-openid==3.1.0 # via flask-openid -pytz==2018.9 # via babel -requests==2.21.0 # via prison -six==1.12.0 # via flask-jwt-extended, flask-marshmallow, prison, python-dateutil -sqlalchemy==1.3.1 # via flask-sqlalchemy, marshmallow-sqlalchemy -urllib3==1.24.1 # via requests -werkzeug==0.14.1 # via flask, flask-jwt-extended -wtforms==2.2.1 # via flask-wtf +pytz==2018.9 +requests==2.21.0 +six==1.12.0 +SQLAlchemy==1.3.1 +Werkzeug==0.14.1 +WTForms==2.2.1 + From a18c50444db506add1ac884d7420cf7079d113a3 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 26 Mar 2019 14:12:05 +0000 Subject: [PATCH 052/109] [api] Only select the selected columns on the DB and better nested fields --- README.rst | 7 ++ flask_appbuilder/api.py | 89 ++++++++++++++++++----- flask_appbuilder/models/sqla/interface.py | 37 +++++++++- flask_appbuilder/tests/test_api.py | 12 ++- 4 files changed, 122 insertions(+), 23 deletions(-) diff --git a/README.rst b/README.rst index cfda3f1386..cdb693cea4 100644 --- a/README.rst +++ b/README.rst @@ -97,6 +97,13 @@ Includes: - Related Select2 fields. - Google charts with automatic group by or direct values and filters. - AddOn system, write your own and contribute. + - CRUD REST API + - Automatic CRUD RESTful APIs. + - Internationalization + - Integration with flask-jwt-extended extension to protect your endpoints. + - Metadata for dynamic rendering. + - Selectable columns and metadata keys. + - Automatic and configurable data validation. - Forms - Automatic, Add, Edit and Show from Database Models - Labels and descriptions for each field. diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 007c35b866..7b753a3b1a 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -36,6 +36,16 @@ log = logging.getLogger(__name__) +from marshmallow import fields + + +class SmartNested(fields.Nested): + def serialize(self, attr, obj, accessor=None): + if attr not in obj.__dict__: + return {"id": int(getattr(obj, attr + ".id"))} + return super(SmartNested, self).serialize(attr, obj, accessor) + + def get_error_msg(): """ (inspired on Superset code) @@ -215,10 +225,12 @@ def _register_urls(self): attr = getattr(self, attr_name) if hasattr(attr, '_urls'): for url, methods in attr._urls: - self.blueprint.add_url_rule(url, - attr_name, - attr, - methods=methods) + self.blueprint.add_url_rule( + url, + attr_name, + attr, + methods=methods + ) @staticmethod def _prettify_name(name): @@ -593,26 +605,63 @@ def _init_model_schemas(self): self._model_schema_factory(self.list_columns) if self.add_model_schema is None: self.add_model_schema = \ - self._model_schema_factory(self.add_columns) + self._model_schema_factory(self.add_columns, nested=False) if self.edit_model_schema is None: self.edit_model_schema = \ - self._model_schema_factory(self.edit_columns) + self._model_schema_factory(self.edit_columns, nested=False) if self.show_model_schema is None: self.show_model_schema = \ self._model_schema_factory(self.show_columns) - def _model_schema_factory(self, columns): + def _meta_schema_factory(self, columns, model, class_mixin): + _model = model + if columns: + class MetaSchema(self.appbuilder.marshmallow.ModelSchema, class_mixin): + class Meta: + model = _model + fields = columns + strict = True + else: + class MetaSchema(self.appbuilder.marshmallow.ModelSchema, class_mixin): + class Meta: + model = _model + strict = True + return MetaSchema + + def _model_schema_factory(self, columns, model=None, nested=True): """ Will create a Marshmallow SQLAlchemy schema class :param columns: List with columns to include :return: ModelSchema object """ - class MetaSchema(self.appbuilder.marshmallow.ModelSchema): - class Meta: - model = self.datamodel.obj - fields = columns - strict = True - return MetaSchema() + _model = model or self.datamodel.obj + + class SchemaMixin: + pass + + _columns = list() + if nested: + for column in columns: + if self.datamodel.is_relation(column): + nested_model = self.datamodel.get_related_model(column) + nested_schema = self._model_schema_factory( + [], + nested_model, + nested=False + ) + if self.datamodel.is_relation_many_to_one(column): + many = False + elif self.datamodel.is_relation_many_to_many(column): + many = True + setattr( + SchemaMixin, + column, + fields.Nested(nested_schema, many=many) + ) + _columns.append(column) + else: + _columns = columns + return self._meta_schema_factory(_columns, _model, SchemaMixin)() def _init_titles(self): """ @@ -896,11 +945,15 @@ def _get_list(self, **kwargs): # handle pagination page_index, page_size = self._handle_page_args(_args) # Make the query - count, lst = self.datamodel.query(joined_filters, - order_column, - order_direction, - page=page_index, - page_size=page_size) + query_select_columns = _pruned_select_cols or self.list_columns + count, lst = self.datamodel.query( + joined_filters, + order_column, + order_direction, + page=page_index, + page_size=page_size, + select_columns=query_select_columns + ) pks = self.datamodel.get_keys(lst) _response[API_RESULT_RES_KEY] = _list_model_schema.dump(lst, many=True).data _response['ids'] = pks diff --git a/flask_appbuilder/models/sqla/interface.py b/flask_appbuilder/models/sqla/interface.py index 3e4b1fc16b..5a81bc9fe6 100644 --- a/flask_appbuilder/models/sqla/interface.py +++ b/flask_appbuilder/models/sqla/interface.py @@ -4,7 +4,7 @@ import sqlalchemy as sa from . import filters -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, load_only, Load, joinedload_all from sqlalchemy.exc import IntegrityError from sqlalchemy import func from sqlalchemy.orm.properties import SynonymProperty @@ -25,6 +25,7 @@ def _include_filters(obj): if not hasattr(obj, key): setattr(obj, key, getattr(filters, key)) + def _is_sqla_type(obj, sa_type): return isinstance(obj, sa_type) or \ isinstance(obj, sa.types.TypeDecorator) and isinstance(obj.impl, sa_type) @@ -62,7 +63,8 @@ def model_name(self): """ return self.obj.__name__ - def _get_base_query(self, query=None, filters=None, order_column='', order_direction=''): + def _get_base_query(self, query=None, filters=None, + order_column='', order_direction=''): if filters: query = filters.apply_all(query) if order_column != '': @@ -77,8 +79,34 @@ def _get_base_query(self, query=None, filters=None, order_column='', order_direc query = query.order_by(self._get_attr(order_column).desc()) return query + def _query_select_options(self, query, select_columns=None): + """ + Add select load options to query. The goal + is to only SQL select what is requested + + :param query: SQLAlchemy Query obj + :param select_columns: (list) of columns + :return: SQLAlchemy Query obj + """ + if select_columns: + _load_options = list() + for column in select_columns: + if '.' in column: + print(column.split('.')[0]) + model_relation = self.get_related_model(column.split('.')[0]) + query = query.join(model_relation) + _load_options.append(Load(model_relation).load_only(column.split('.')[1])) + else: + if (not self.is_relation(column) and + not hasattr(getattr(self.obj, column), '__call__')): + _load_options.append(Load(self.obj).load_only(column)) + else: + _load_options.append(Load(self.obj)) + query = query.options(*tuple(_load_options)) + return query + def query(self, filters=None, order_column='', order_direction='', - page=None, page_size=None): + page=None, page_size=None, select_columns=None): """ QUERY :param filters: @@ -94,6 +122,7 @@ def query(self, filters=None, order_column='', order_direction='', """ query = self.session.query(self.obj) + query = self._query_select_options(query, select_columns) if len(order_column.split('.')) >= 2: tmp_order_column = '' for join_relation in order_column.split('.')[:-1]: @@ -118,7 +147,7 @@ def query(self, filters=None, order_column='', order_direction='', query = query.offset(page * page_size) if page_size: query = query.limit(page_size) - + print(query.all()) return count, query.all() def query_simple_group(self, group_by='', aggregate_func=None, aggregate_col=None, filters=None): diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 0ba6206f1c..6602986fa3 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -442,7 +442,17 @@ def test_get_item_rel_field(self): ) data = json.loads(rv.data.decode('utf-8')) eq_(rv.status_code, 200) - eq_(data[API_RESULT_RES_KEY], {'group': 1}) + expected_rel_field = { + 'group': + { + 'field_date': None, + 'field_float': 0.0, + 'field_integer': 0, + 'field_string': 'test0', + 'id': 1 + } + } + eq_(data[API_RESULT_RES_KEY], expected_rel_field) def test_get_list(self): """ From e123f34963a7920b1f34d5464753aa7f20f4e8d2 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 26 Mar 2019 14:19:24 +0000 Subject: [PATCH 053/109] [api] Fix, remove debug --- flask_appbuilder/api.py | 12 +----------- flask_appbuilder/models/sqla/interface.py | 2 -- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 7b753a3b1a..7bf1d2cccf 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -7,7 +7,7 @@ from werkzeug.exceptions import BadRequest from flask_babel import lazy_gettext as _ from .security.decorators import permission_name, protect -from marshmallow import ValidationError +from marshmallow import ValidationError, fields from ._compat import as_unicode from .const import ( API_URI_RIS_KEY, @@ -36,16 +36,6 @@ log = logging.getLogger(__name__) -from marshmallow import fields - - -class SmartNested(fields.Nested): - def serialize(self, attr, obj, accessor=None): - if attr not in obj.__dict__: - return {"id": int(getattr(obj, attr + ".id"))} - return super(SmartNested, self).serialize(attr, obj, accessor) - - def get_error_msg(): """ (inspired on Superset code) diff --git a/flask_appbuilder/models/sqla/interface.py b/flask_appbuilder/models/sqla/interface.py index 5a81bc9fe6..500b131421 100644 --- a/flask_appbuilder/models/sqla/interface.py +++ b/flask_appbuilder/models/sqla/interface.py @@ -92,7 +92,6 @@ def _query_select_options(self, query, select_columns=None): _load_options = list() for column in select_columns: if '.' in column: - print(column.split('.')[0]) model_relation = self.get_related_model(column.split('.')[0]) query = query.join(model_relation) _load_options.append(Load(model_relation).load_only(column.split('.')[1])) @@ -147,7 +146,6 @@ def query(self, filters=None, order_column='', order_direction='', query = query.offset(page * page_size) if page_size: query = query.limit(page_size) - print(query.all()) return count, query.all() def query_simple_group(self, group_by='', aggregate_func=None, aggregate_col=None, filters=None): From 36394d840a17c10cab0f887eabf02b6125171a97 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 27 Mar 2019 14:08:12 +0000 Subject: [PATCH 054/109] [api] Fixes and Enum class support --- flask_appbuilder/api.py | 86 +++++++++++++++++++-------- flask_appbuilder/base.py | 3 - flask_appbuilder/security/manager.py | 15 +++-- flask_appbuilder/tests/sqla/models.py | 15 +++-- flask_appbuilder/tests/test_api.py | 36 ++++++++--- setup.py | 6 +- 6 files changed, 112 insertions(+), 49 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 7bf1d2cccf..3dd5af6517 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -8,6 +8,8 @@ from flask_babel import lazy_gettext as _ from .security.decorators import permission_name, protect from marshmallow import ValidationError, fields +from marshmallow_sqlalchemy import field_for +from marshmallow_enum import EnumField from ._compat import as_unicode from .const import ( API_URI_RIS_KEY, @@ -603,54 +605,82 @@ def _init_model_schemas(self): self.show_model_schema = \ self._model_schema_factory(self.show_columns) + @staticmethod + def _debug_schema(schema): + for k, v in schema._declared_fields.items(): + print(k, v) + def _meta_schema_factory(self, columns, model, class_mixin): + from marshmallow_sqlalchemy.schema import ModelSchema _model = model if columns: - class MetaSchema(self.appbuilder.marshmallow.ModelSchema, class_mixin): + class MetaSchema(ModelSchema, class_mixin): class Meta: model = _model fields = columns strict = True + sqla_session = self.appbuilder.get_session else: - class MetaSchema(self.appbuilder.marshmallow.ModelSchema, class_mixin): + class MetaSchema(ModelSchema, class_mixin): class Meta: model = _model strict = True + sqla_session = self.appbuilder.get_session return MetaSchema + def _column2field(self, datamodel, column, nested=True): + _model = datamodel.obj + # Handle relations + if datamodel.is_relation(column) and nested: + required = not datamodel.is_nullable(column) + nested_model = datamodel.get_related_model(column) + nested_schema = self._model_schema_factory( + [], + nested_model, + nested=False + ) + if datamodel.is_relation_many_to_one(column): + many = False + elif datamodel.is_relation_many_to_many(column): + many = True + else: + many = False + return fields.Nested(nested_schema, many=many, required=required) + elif datamodel.is_relation(column): + required = not datamodel.is_nullable(column) + field = field_for(_model, column) + field.required = required + return field + # Handle Enums + elif datamodel.is_enum(column): + required = not datamodel.is_nullable(column) + enum_class = datamodel.list_columns[column].info.get('enum_class') + return EnumField(enum_class, dump_by=EnumField.VALUE, required=required) + def _model_schema_factory(self, columns, model=None, nested=True): """ Will create a Marshmallow SQLAlchemy schema class :param columns: List with columns to include :return: ModelSchema object """ - _model = model or self.datamodel.obj - class SchemaMixin: pass + _model = model or self.datamodel.obj + _datamodel = self.datamodel.__class__(_model) + + ma_sqla_fields_override = {} + _columns = list() - if nested: - for column in columns: - if self.datamodel.is_relation(column): - nested_model = self.datamodel.get_related_model(column) - nested_schema = self._model_schema_factory( - [], - nested_model, - nested=False - ) - if self.datamodel.is_relation_many_to_one(column): - many = False - elif self.datamodel.is_relation_many_to_many(column): - many = True - setattr( - SchemaMixin, - column, - fields.Nested(nested_schema, many=many) - ) - _columns.append(column) - else: - _columns = columns + for column in columns: + ma_sqla_fields_override[column] = self._column2field( + _datamodel, + column, + nested + ) + _columns.append(column) + for k, v in ma_sqla_fields_override.items(): + setattr(SchemaMixin, k, v) return self._meta_schema_factory(_columns, _model, SchemaMixin)() def _init_titles(self): @@ -687,7 +717,7 @@ def _init_properties(self): list(self.list_model_schema._declared_fields.keys()) else: self.list_columns = self.list_columns or [ - x for x in self.datamodel.get_columns_list() + x for x in self.datamodel.get_user_columns_list() if x not in self.list_exclude_columns ] @@ -769,6 +799,8 @@ def get(self, pk=None): @safe @permission_name('post') def post(self): + if not request.is_json: + return self.response(400, **{'message': 'Request is not JSON'}) try: item = self.add_model_schema.load(request.json) except ValidationError as err: @@ -798,6 +830,8 @@ def post(self): @permission_name('put') def put(self, pk): item = self.datamodel.get(pk, self._base_filters) + if not request.is_json: + return self.response(400, **{'message': 'Request is not JSON'}) if not item: return self.response_404() else: diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index 4f104f3d41..d74a10840a 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -1,7 +1,6 @@ import logging from flask import Blueprint, url_for, current_app -from flask_marshmallow import Marshmallow from .views import IndexView, UtilView from .filters import TemplateFilters from .menu import Menu @@ -134,7 +133,6 @@ def __init__(self, app=None, self.app = app - self.marshmallow = Marshmallow() if app is not None: self.init_app(app, session) @@ -166,7 +164,6 @@ def init_app(self, app, session): self._add_admin_views() self._add_addon_views() self._add_menu_permissions() - self.marshmallow.init_app(self.app) if not self.app: for baseview in self.baseviews: # instantiate the views and add session diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index 5d8543af31..cefa7c82a3 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -1014,11 +1014,16 @@ def get_user_permissions_on_view(view_name): """ _ret = list() if current_user.is_authenticated: - for role in current_user.roles: - if role.permissions: - for permission in role.permissions: - if permission.view_menu.name == view_name: - _ret.append(permission.permission.name) + _current_user = current_user + elif current_user_jwt: + _current_user = current_user_jwt + else: + return _ret + for role in _current_user.roles: + if role.permissions: + for permission in role.permissions: + if permission.view_menu.name == view_name: + _ret.append(permission.permission.name) return _ret def add_permissions_view(self, base_permissions, view_menu): diff --git a/flask_appbuilder/tests/sqla/models.py b/flask_appbuilder/tests/sqla/models.py index 9fafab3841..8faf9c6010 100644 --- a/flask_appbuilder/tests/sqla/models.py +++ b/flask_appbuilder/tests/sqla/models.py @@ -1,8 +1,8 @@ +import enum from sqlalchemy import Column, Integer, String, ForeignKey, Date, Float, Enum, DateTime from sqlalchemy.orm import relationship from flask_appbuilder import Model, SQLA -import enum class Model1(Model): @@ -52,14 +52,13 @@ def __repr__(self): class TmpEnum(enum.Enum): - e1 = 'a' - e2 = 2 + e1 = 'one' + e2 = 'two' class ModelWithEnums(Model): id = Column(Integer, primary_key=True) - enum1 = Column(Enum('e1', 'e2')) - enum2 = Column(Enum(TmpEnum)) + enum1 = Column(Enum(TmpEnum), info={'enum_class': TmpEnum}) """ --------------------------------- @@ -86,3 +85,9 @@ def insert_data(session, count): model.group = model1_collection[i] session.add(model) session.commit() + for i in range(count): + model = ModelWithEnums() + model.enum1 = 'e1' + model.enum2 = TmpEnum.e2 + session.add(model) + session.commit() diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 6602986fa3..42f96840ad 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -5,7 +5,8 @@ import prison from nose.tools import eq_ from flask_appbuilder import SQLA -from .sqla.models import Model1, Model2, insert_data +from .sqla.models import Model1, Model2, ModelWithEnums, TmpEnum, \ + insert_data from flask_appbuilder.models.sqla.filters import \ FilterGreater, FilterSmaller from flask_appbuilder.const import ( @@ -133,6 +134,9 @@ class Model1ApiFiltered(ModelRestApi): ['field_integer', FilterSmaller, 4] ] + class ModelWithEnumsApi(ModelRestApi): + datamodel = SQLAInterface(ModelWithEnums) + self.model1api = Model1Api self.appbuilder.add_view_no_menu(Model1Api) self.model1funcapi = Model1Api @@ -142,6 +146,7 @@ class Model1ApiFiltered(ModelRestApi): self.appbuilder.add_view_no_menu(Model1ApiOrder) self.appbuilder.add_view_no_menu(Model1ApiFiltered) self.appbuilder.add_view_no_menu(Model1ApiExcludeCols) + self.appbuilder.add_view_no_menu(ModelWithEnumsApi) class Model2Api(ModelRestApi): datamodel = SQLAInterface(Model2) @@ -552,7 +557,6 @@ def test_get_list_base_order(self): ) data = json.loads(rv.data.decode('utf-8')) eq_(data[API_RESULT_RES_KEY][0], { - 'id': MODEL1_DATA_SIZE, 'field_date': None, 'field_float': float(MODEL1_DATA_SIZE - 1), 'field_integer': MODEL1_DATA_SIZE - 1, @@ -574,7 +578,6 @@ def test_get_list_base_order(self): ) data = json.loads(rv.data.decode('utf-8')) eq_(data[API_RESULT_RES_KEY][0], { - 'id': 1, 'field_date': None, 'field_float': 0.0, 'field_integer': 0, @@ -794,7 +797,6 @@ def test_get_list_exclude_cols(self): ) data = json.loads(rv.data.decode('utf-8')) eq_(data[API_RESULT_RES_KEY][0], { - 'id': 1, 'field_string': 'test0' }) @@ -825,7 +827,6 @@ def test_get_list_base_filters(self): 'field_float': 3.0, 'field_integer': 3, 'field_string': 'test3', - 'id': 4 } ] eq_(data[API_RESULT_RES_KEY], expected_result) @@ -945,7 +946,7 @@ def test_info_fields_rel_field(self): 'description': '', 'label': 'Group', 'name': 'group', - 'required': False, + 'required': True, 'type': 'Related', 'values': [] } @@ -978,7 +979,7 @@ def test_info_fields_rel_filtered_field(self): 'description': '', 'label': 'Group', 'name': 'group', - 'required': False, + 'required': True, 'type': 'Related', 'values': [ { @@ -1369,6 +1370,27 @@ def test_create_item_excluded_cols(self): eq_(model.field_float, None) eq_(model.field_date, None) + def test_create_item_with_enum(self): + """ + REST Api: Test create item with enum + """ + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + item = dict( + enum1='e1' + ) + uri = 'api/v1/modelwithenumsapi/' + rv = self.auth_client_post( + client, + token, + uri, + item + ) + data = json.loads(rv.data.decode('utf-8')) + eq_(rv.status_code, 201) + model = self.db.session.query(ModelWithEnums).get(data['id']) + eq_(model.enum1, TmpEnum.e1) + def test_get_list_col_function(self): """ REST Api: Test get list of objects with columns as functions diff --git a/setup.py b/setup.py index 93b0722406..22ec83947a 100644 --- a/setup.py +++ b/setup.py @@ -45,9 +45,9 @@ def desc(): 'Flask-WTF>=0.14.2,<1', 'Flask-JWT-Extended>=3.18,<4', 'python-dateutil>=2.3,<3', - 'flask-marshmallow==0.9.0', - 'marshmallow==2.18.0', - 'marshmallow-sqlalchemy==0.15.0', + 'marshmallow>=2.18.0,<2.19', + 'marshmallow-enum>=1.4.1,<2' + 'marshmallow-sqlalchemy>=0.16.1<1', 'prison==0.1.0', 'PyJWT>=1.7.1' ], From 7c149c64259ac2695ab57c04c903a468aebf8e61 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 27 Mar 2019 17:35:48 +0000 Subject: [PATCH 055/109] [api] Tests for M-M and authorization, several small fixes (exceptions) --- flask_appbuilder/api.py | 98 ++++++++-------- flask_appbuilder/models/sqla/filters.py | 1 + flask_appbuilder/models/sqla/interface.py | 18 ++- flask_appbuilder/tests/sqla/models.py | 41 ++++++- flask_appbuilder/tests/test_api.py | 133 +++++++++++++++++++++- 5 files changed, 234 insertions(+), 57 deletions(-) diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 3dd5af6517..12920ec90e 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -3,6 +3,7 @@ import functools import traceback import prison +from sqlalchemy.exc import IntegrityError from flask import Blueprint, make_response, jsonify, request, current_app from werkzeug.exceptions import BadRequest from flask_babel import lazy_gettext as _ @@ -646,6 +647,7 @@ def _column2field(self, datamodel, column, nested=True): else: many = False return fields.Nested(nested_schema, many=many, required=required) + # Handle bug on marshmallow-sqlalchemy #163 elif datamodel.is_relation(column): required = not datamodel.is_nullable(column) field = field_for(_model, column) @@ -766,9 +768,9 @@ def merge_search_filters(self, response, **kwargs): response[API_FILTERS_RES_KEY] = search_filters @expose('/_info', methods=['GET']) + @safe @protect @rison - @safe @permission_name('get') @merge_response_func(BaseApi.merge_current_user_permissions, API_PERMISSIONS_RIS_KEY) @merge_response_func(merge_add_field_info, API_ADD_COLUMNS_RIS_KEY) @@ -800,29 +802,29 @@ def get(self, pk=None): @permission_name('post') def post(self): if not request.is_json: - return self.response(400, **{'message': 'Request is not JSON'}) + return self.response_400(message='Request is not JSON') try: item = self.add_model_schema.load(request.json) except ValidationError as err: - return self.response(400, **{'message': err.messages}) - else: - # This validates custom Schema with custom validations - if isinstance(item.data, dict): - return self.response(400, **{'message': item.errors}) - self.pre_add(item.data) - if self.datamodel.add(item.data): - self.post_add(item.data) - return self.response( - 201, - **{ - API_RESULT_RES_KEY: self.add_model_schema.dump( - item.data, many=False - ).data, - 'id': self.datamodel.get_pk_value(item.data) - } - ) - else: - return self.response_500() + return self.response_400(message=err.messages) + # This validates custom Schema with custom validations + if isinstance(item.data, dict): + return self.response_400(message=item.errors) + self.pre_add(item.data) + try: + self.datamodel.add(item.data, raise_exception=True) + self.post_add(item.data) + return self.response( + 201, + **{ + API_RESULT_RES_KEY: self.add_model_schema.dump( + item.data, many=False + ).data, + 'id': self.datamodel.get_pk_value(item.data) + } + ) + except IntegrityError as e: + return self.response_400(message=str(e.orig)) @expose('/', methods=['PUT']) @protect @@ -834,27 +836,25 @@ def put(self, pk): return self.response(400, **{'message': 'Request is not JSON'}) if not item: return self.response_404() - else: - try: - item = self.edit_model_schema.load(request.json, instance=item) - except ValidationError as err: - return self.response(400, **{'message': err.messages}) - else: - # This validates custom Schema with custom validations - if isinstance(item.data, dict): - return self.response(400, **{'message': item.errors}) - self.pre_update(item.data) - if self.datamodel.edit(item.data): - self.post_add(item) - self.post_update(item) - return self.response( - 200, - **{API_RESULT_RES_KEY: self.edit_model_schema.dump( - item.data, - many=False).data} - ) - else: - return self.response_500() + try: + item = self.edit_model_schema.load(request.json, instance=item) + except ValidationError as err: + return self.response(400, **{'message': err.messages}) + # This validates custom Schema with custom validations + if isinstance(item.data, dict): + return self.response(400, **{'message': item.errors}) + self.pre_update(item.data) + try: + self.datamodel.edit(item.data, raise_exception=True) + self.post_update(item) + return self.response( + 200, + **{API_RESULT_RES_KEY: self.edit_model_schema.dump( + item.data, + many=False).data} + ) + except IntegrityError as e: + return self.response_400(message=str(e.orig)) @expose('/', methods=['DELETE']) @protect @@ -864,13 +864,13 @@ def delete(self, pk): item = self.datamodel.get(pk, self._base_filters) if not item: return self.response_404() - else: - self.pre_delete(item) - if self.datamodel.delete(item): - self.post_delete(item) - return self.response(200, message='OK') - else: - return self.response_500() + self.pre_delete(item) + try: + self.datamodel.delete(item, raise_exception=True) + self.post_delete(item) + return self.response(200, message='OK') + except IntegrityError as e: + return self.response_400(message=str(e.orig)) def merge_label_columns(self, response, **kwargs): _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) diff --git a/flask_appbuilder/models/sqla/filters.py b/flask_appbuilder/models/sqla/filters.py index c22694058d..9d6df1a499 100644 --- a/flask_appbuilder/models/sqla/filters.py +++ b/flask_appbuilder/models/sqla/filters.py @@ -12,6 +12,7 @@ 'FilterRelationManyToManyEqual', 'FilterRelationOneToManyEqual', 'FilterRelationOneToManyNotEqual', 'FilterSmaller'] + def get_field_setup_query(query, model, column_name): """ Help function for SQLA filters, checks for dot notation on column names. diff --git a/flask_appbuilder/models/sqla/interface.py b/flask_appbuilder/models/sqla/interface.py index 500b131421..478646ceb4 100644 --- a/flask_appbuilder/models/sqla/interface.py +++ b/flask_appbuilder/models/sqla/interface.py @@ -329,7 +329,7 @@ def get_max_length(self, col_name): ------------------------------- """ - def add(self, item): + def add(self, item, raise_exception=False): try: self.session.add(item) self.session.commit() @@ -339,14 +339,18 @@ def add(self, item): self.message = (as_unicode(self.add_integrity_error_message), 'warning') log.warning(LOGMSG_WAR_DBI_ADD_INTEGRITY.format(str(e))) self.session.rollback() + if raise_exception: + raise e return False except Exception as e: self.message = (as_unicode(self.general_error_message + ' ' + str(sys.exc_info()[0])), 'danger') log.exception(LOGMSG_ERR_DBI_ADD_GENERIC.format(str(e))) self.session.rollback() + if raise_exception: + raise e return False - def edit(self, item): + def edit(self, item, raise_exception=False): try: self.session.merge(item) self.session.commit() @@ -356,14 +360,18 @@ def edit(self, item): self.message = (as_unicode(self.edit_integrity_error_message), 'warning') log.warning(LOGMSG_WAR_DBI_EDIT_INTEGRITY.format(str(e))) self.session.rollback() + if raise_exception: + raise e return False except Exception as e: self.message = (as_unicode(self.general_error_message + ' ' + str(sys.exc_info()[0])), 'danger') log.exception(LOGMSG_ERR_DBI_EDIT_GENERIC.format(str(e))) self.session.rollback() + if raise_exception: + raise e return False - def delete(self, item): + def delete(self, item, raise_exception=False): try: self._delete_files(item) self.session.delete(item) @@ -374,11 +382,15 @@ def delete(self, item): self.message = (as_unicode(self.delete_integrity_error_message), 'warning') log.warning(LOGMSG_WAR_DBI_DEL_INTEGRITY.format(str(e))) self.session.rollback() + if raise_exception: + raise e return False except Exception as e: self.message = (as_unicode(self.general_error_message + ' ' + str(sys.exc_info()[0])), 'danger') log.exception(LOGMSG_ERR_DBI_DEL_GENERIC.format(str(e))) self.session.rollback() + if raise_exception: + raise e return False def delete_all(self, items): diff --git a/flask_appbuilder/tests/sqla/models.py b/flask_appbuilder/tests/sqla/models.py index 8faf9c6010..e6b27f3701 100644 --- a/flask_appbuilder/tests/sqla/models.py +++ b/flask_appbuilder/tests/sqla/models.py @@ -1,10 +1,10 @@ import enum -from sqlalchemy import Column, Integer, String, ForeignKey, Date, Float, Enum, DateTime +from sqlalchemy import Column, Integer, String, \ + ForeignKey, Date, Float, Enum, DateTime, Table, UniqueConstraint from sqlalchemy.orm import relationship from flask_appbuilder import Model, SQLA - class Model1(Model): id = Column(Integer, primary_key=True) field_string = Column(String(50), unique=True, nullable=False) @@ -61,6 +61,28 @@ class ModelWithEnums(Model): enum1 = Column(Enum(TmpEnum), info={'enum_class': TmpEnum}) +assoc_parent_child = Table( + 'parent_child', Model.metadata, + Column('id', Integer, primary_key=True), + Column('parent_id', Integer, ForeignKey('parent.id')), + Column('child_id', Integer, ForeignKey('child.id')), + UniqueConstraint('parent_id', 'child_id') +) + + +class ModelMMParent(Model): + __tablename__ = 'parent' + id = Column(Integer, primary_key=True) + field_string = Column(String(50), unique=True, nullable=False) + children = relationship('ModelMMChild', secondary=assoc_parent_child) + + +class ModelMMChild(Model): + __tablename__ = 'child' + id = Column(Integer, primary_key=True) + field_string = Column(String(50), unique=True, nullable=False) + + """ --------------------------------- TEST HELPER FUNCTIONS --------------------------------- @@ -91,3 +113,18 @@ def insert_data(session, count): model.enum2 = TmpEnum.e2 session.add(model) session.commit() + + children = list() + for i in range(1, 4): + model = ModelMMChild() + model.field_string = str(i) + children.append(model) + session.add(model) + session.commit() + for i in range(count): + model = ModelMMParent() + model.field_string = str(i) + model.children = children + session.add(model) + session.commit() + diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 42f96840ad..ccc823b5d6 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -6,7 +6,7 @@ from nose.tools import eq_ from flask_appbuilder import SQLA from .sqla.models import Model1, Model2, ModelWithEnums, TmpEnum, \ - insert_data + ModelMMParent, ModelMMChild, insert_data from flask_appbuilder.models.sqla.filters import \ FilterGreater, FilterSmaller from flask_appbuilder.const import ( @@ -127,6 +127,10 @@ class Model1ApiOrder(ModelRestApi): datamodel = SQLAInterface(Model1) base_order = ('field_integer', 'desc') + class Model1ApiRestrictedPermissions(ModelRestApi): + datamodel = SQLAInterface(Model1) + base_permissions = ['can_get'] + class Model1ApiFiltered(ModelRestApi): datamodel = SQLAInterface(Model1) base_filters = [ @@ -137,6 +141,9 @@ class Model1ApiFiltered(ModelRestApi): class ModelWithEnumsApi(ModelRestApi): datamodel = SQLAInterface(ModelWithEnums) + class ModelMMApi(ModelRestApi): + datamodel = SQLAInterface(ModelMMParent) + self.model1api = Model1Api self.appbuilder.add_view_no_menu(Model1Api) self.model1funcapi = Model1Api @@ -147,6 +154,8 @@ class ModelWithEnumsApi(ModelRestApi): self.appbuilder.add_view_no_menu(Model1ApiFiltered) self.appbuilder.add_view_no_menu(Model1ApiExcludeCols) self.appbuilder.add_view_no_menu(ModelWithEnumsApi) + self.appbuilder.add_view_no_menu(Model1ApiRestrictedPermissions) + self.appbuilder.add_view_no_menu(ModelMMApi) class Model2Api(ModelRestApi): datamodel = SQLAInterface(Model2) @@ -282,6 +291,43 @@ def test_auth_login_bad(self): ) eq_(rv.status_code, 400) + def test_auth_authorization(self): + """ + REST Api: Test auth base limited authorization + """ + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + + pk = 1 + uri = 'api/v1/model1apirestrictedpermissions/{}'.format(pk) + rv = self.auth_client_delete( + client, + token, + uri + ) + eq_(rv.status_code, 401) + item = dict( + field_string="test{}".format(MODEL1_DATA_SIZE+1), + field_integer=MODEL1_DATA_SIZE+1, + field_float=float(MODEL1_DATA_SIZE+1), + field_date=None + ) + uri = 'api/v1/model1apirestrictedpermissions/' + rv = self.auth_client_post( + client, + token, + uri, + item + ) + eq_(rv.status_code, 401) + uri = 'api/v1/model1apirestrictedpermissions/1' + rv = self.auth_client_get( + client, + token, + uri + ) + eq_(rv.status_code, 200) + def test_get_item(self): """ REST Api: Test get item @@ -431,9 +477,9 @@ def test_get_item_base_filters(self): ) eq_(rv.status_code, 200) - def test_get_item_rel_field(self): + def test_get_item_1m_field(self): """ - REST Api: Test get item with with related fields + REST Api: Test get item with 1-N related field """ client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) @@ -459,6 +505,29 @@ def test_get_item_rel_field(self): } eq_(data[API_RESULT_RES_KEY], expected_rel_field) + def test_get_item_mm_field(self): + """ + REST Api: Test get item with N-N releted field + """ + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + + # We can't get a base filtered item + pk = 1 + rv = self.auth_client_get( + client, + token, + 'api/v1/modelmmapi/{}'.format(pk) + ) + data = json.loads(rv.data.decode('utf-8')) + eq_(rv.status_code, 200) + expected_rel_field = [ + {'field_string': '1', 'id': 1}, + {'field_string': '2', 'id': 2}, + {'field_string': '3', 'id': 3} + ] + eq_(data[API_RESULT_RES_KEY]['children'], expected_rel_field) + def test_get_list(self): """ REST Api: Test get list @@ -995,6 +1064,38 @@ def test_info_fields_rel_filtered_field(self): if rel_field['name'] == 'group': eq_(rel_field, expected_rel_add_field) + def test_info_permissions(self): + """ + REST Api: Test info permissions + """ + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + uri = 'api/v1/model1api/_info' + rv = self.auth_client_get( + client, + token, + uri + ) + data = json.loads(rv.data.decode('utf-8')) + expected_permissions = [ + 'can_delete', + 'can_get', + 'can_post', + 'can_put', + ] + eq_(sorted(data[API_PERMISSIONS_RES_KEY]), expected_permissions) + uri = 'api/v1/model1apirestrictedpermissions/_info' + rv = self.auth_client_get( + client, + token, + uri + ) + data = json.loads(rv.data.decode('utf-8')) + expected_permissions = [ + 'can_get', + ] + eq_(sorted(data[API_PERMISSIONS_RES_KEY]), expected_permissions) + def test_info_select_meta_data(self): """ REST Api: Test info select meta data @@ -1184,6 +1285,32 @@ def test_update_val_size(self): data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Longer than maximum length 50.') + def test_update_mm_field(self): + """ + REST Api: Test update m-m field + """ + model = ModelMMChild() + model.field_string = 'update_m,m' + xpto = self.appbuilder.get_session.add(model) + self.appbuilder.get_session.commit() + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + pk = 1 + item = dict( + children=[4], + field_string='0' + ) + uri = 'api/v1/modelmmapi/{}'.format(pk) + rv = self.auth_client_put( + client, + token, + uri, + item + ) + eq_(rv.status_code, 200) + data = json.loads(rv.data.decode('utf-8')) + eq_(data[API_RESULT_RES_KEY], {"children": [4], "field_string": "0"}) + def test_update_item_val_type(self): """ REST Api: Test update validate type From bcaef94610cba63b7f002bb28dc21164a5bb831b Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 27 Mar 2019 18:16:01 +0000 Subject: [PATCH 056/109] [api] Fix, tests for Enum on py3 --- flask_appbuilder/tests/sqla/models.py | 7 ++++--- flask_appbuilder/tests/test_api.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/flask_appbuilder/tests/sqla/models.py b/flask_appbuilder/tests/sqla/models.py index e6b27f3701..ea0b109577 100644 --- a/flask_appbuilder/tests/sqla/models.py +++ b/flask_appbuilder/tests/sqla/models.py @@ -52,13 +52,14 @@ def __repr__(self): class TmpEnum(enum.Enum): - e1 = 'one' - e2 = 'two' + e1 = 'a' + e2 = 2 class ModelWithEnums(Model): id = Column(Integer, primary_key=True) - enum1 = Column(Enum(TmpEnum), info={'enum_class': TmpEnum}) + enum1 = Column(Enum('e1', 'e2')) + enum2 = Column(Enum(TmpEnum), info={'enum_class': TmpEnum}) assoc_parent_child = Table( diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index ccc823b5d6..fb44353723 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -1504,7 +1504,7 @@ def test_create_item_with_enum(self): client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) item = dict( - enum1='e1' + enum2='e1' ) uri = 'api/v1/modelwithenumsapi/' rv = self.auth_client_post( @@ -1516,7 +1516,7 @@ def test_create_item_with_enum(self): data = json.loads(rv.data.decode('utf-8')) eq_(rv.status_code, 201) model = self.db.session.query(ModelWithEnums).get(data['id']) - eq_(model.enum1, TmpEnum.e1) + eq_(model.enum2, TmpEnum.e1) def test_get_list_col_function(self): """ From 017f0ea993061e2dd427e7132f9336f7460f0ff6 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 28 Mar 2019 10:18:39 +0000 Subject: [PATCH 057/109] [api] New, allow_browser_login option to enable cookie sessions --- docs/rest_api.rst | 18 +++++- flask_appbuilder/api.py | 17 +++-- flask_appbuilder/security/decorators.py | 86 ++++++++++++++++--------- 3 files changed, 85 insertions(+), 36 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index bbcd3c2595..260c94da3f 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -259,7 +259,7 @@ Next, let's see how to create a private method:: ... @expose('/private') - @protect + @protect() def rison_json(self): return self.response(200, message="This is private") @@ -350,6 +350,22 @@ user Model:: ... return user +Optionally you can enable signed cookie sessions (from flask-login) on the +API. You can do it class or method wide:: + + class MyFirstApi(BaseApi): + allow_browser_login = True + +The previous example will enable cookie sessions on the all class:: + + class MyFirstApi(BaseApi): + + @expose('/private') + @protect(allow_browser_login=True) + def private(self) + .... + +On the previous example, we are enabling signed cookies on the ``private`` method Model REST Api -------------- diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 12920ec90e..c0ad2aacfd 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -168,6 +168,11 @@ class ExampleApi(BaseApi): base_permissions = ['can_get'] """ + allow_browser_login = False + """ + Will allow flask-login cookie authorization on the API + default is False. + """ extra_args = None def __init__(self): @@ -768,10 +773,10 @@ def merge_search_filters(self, response, **kwargs): response[API_FILTERS_RES_KEY] = search_filters @expose('/_info', methods=['GET']) + @protect() @safe - @protect @rison - @permission_name('get') + @permission_name('info') @merge_response_func(BaseApi.merge_current_user_permissions, API_PERMISSIONS_RIS_KEY) @merge_response_func(merge_add_field_info, API_ADD_COLUMNS_RIS_KEY) @merge_response_func(merge_edit_field_info, API_EDIT_COLUMNS_RIS_KEY) @@ -788,7 +793,7 @@ def info(self, **kwargs): @expose('/', methods=['GET']) @expose('/', methods=['GET']) - @protect + @protect() @safe @permission_name('get') def get(self, pk=None): @@ -797,7 +802,7 @@ def get(self, pk=None): return self._get_item(pk) @expose('/', methods=['POST']) - @protect + @protect() @safe @permission_name('post') def post(self): @@ -827,7 +832,7 @@ def post(self): return self.response_400(message=str(e.orig)) @expose('/', methods=['PUT']) - @protect + @protect() @safe @permission_name('put') def put(self, pk): @@ -857,7 +862,7 @@ def put(self, pk): return self.response_400(message=str(e.orig)) @expose('/', methods=['DELETE']) - @protect + @protect() @safe @permission_name('delete') def delete(self, pk): diff --git a/flask_appbuilder/security/decorators.py b/flask_appbuilder/security/decorators.py index 8533d5afc2..c2e377d9ad 100644 --- a/flask_appbuilder/security/decorators.py +++ b/flask_appbuilder/security/decorators.py @@ -17,42 +17,60 @@ log = logging.getLogger(__name__) -def protect(f): +def protect(allow_browser_login=False): """ Use this decorator to enable granular security permissions to your API methods (BaseApi and child classes). Permissions will be associated to a role, and roles are associated to users. + allow_browser_login will accept signed cookies obtained from the normal MVC app:: + + class MyApi(BaseApi): + @expose('/dosonmething', methods=['GET']) + @protect(allow_browser_login=True) + @safe + def do_something(self): + .... + + @expose('/dosonmethingelse', methods=['GET']) + @protect() + @safe + def do_something_else(self): + .... + By default the permission's name is the methods name. """ - if hasattr(f, '_permission_name'): - permission_str = f._permission_name - else: - permission_str = f.__name__ - - def wraps(self, *args, **kwargs): - permission_str = "{}{}".format(PERMISSION_PREFIX, f._permission_name) - if current_app.appbuilder.sm.is_item_public( - permission_str, - self.__class__.__name__ - ): - return f(self, *args, **kwargs) - verify_jwt_in_request() - if current_app.appbuilder.sm.has_access( - permission_str, - self.__class__.__name__ - ): - return f(self, *args, **kwargs) + def _protect(f): + if hasattr(f, '_permission_name'): + permission_str = f._permission_name else: - log.warning( - LOGMSG_ERR_SEC_ACCESS_DENIED.format( + permission_str = f.__name__ + + def wraps(self, *args, **kwargs): + permission_str = "{}{}".format(PERMISSION_PREFIX, f._permission_name) + if current_app.appbuilder.sm.is_item_public( + permission_str, + self.__class__.__name__ + ): + return f(self, *args, **kwargs) + if not (self.allow_browser_login or allow_browser_login): + verify_jwt_in_request() + if current_app.appbuilder.sm.has_access( permission_str, self.__class__.__name__ + ): + return f(self, *args, **kwargs) + else: + log.warning( + LOGMSG_ERR_SEC_ACCESS_DENIED.format( + permission_str, + self.__class__.__name__ + ) ) - ) - return self.response_401() - f._permission_name = permission_str - return functools.update_wrapper(wraps, f) + return self.response_401() + f._permission_name = permission_str + return functools.update_wrapper(wraps, f) + return functools.update_wrapper(_protect, allow_browser_login) def has_access(f): @@ -76,10 +94,18 @@ def wraps(self, *args, **kwargs): return f(self, *args, **kwargs) else: log.warning( - LOGMSG_ERR_SEC_ACCESS_DENIED.format(permission_str, self.__class__.__name__) + LOGMSG_ERR_SEC_ACCESS_DENIED.format( + permission_str, + self.__class__.__name__ + ) ) flash(as_unicode(FLAMSG_ERR_SEC_ACCESS_DENIED), "danger") - return redirect(url_for(self.appbuilder.sm.auth_view.__class__.__name__ + ".login", next=request.url)) + return redirect( + url_for( + self.appbuilder.sm.auth_view.__class__.__name__ + ".login", + next=request.url + ) + ) f._permission_name = permission_str return functools.update_wrapper(wraps, f) @@ -107,7 +133,10 @@ def wraps(self, *args, **kwargs): return f(self, *args, **kwargs) else: log.warning( - LOGMSG_ERR_SEC_ACCESS_DENIED.format(permission_str, self.__class__.__name__) + LOGMSG_ERR_SEC_ACCESS_DENIED.format( + permission_str, + self.__class__.__name__ + ) ) response = make_response( jsonify( @@ -120,7 +149,6 @@ def wraps(self, *args, **kwargs): ) response.headers['Content-Type'] = "application/json" return response - return redirect(url_for(self.appbuilder.sm.auth_view.__class__.__name__ + ".login", next=request.url)) f._permission_name = permission_str return functools.update_wrapper(wraps, f) From 12dd41e1f95a5482ceddf869127e8618e4455166 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 28 Mar 2019 10:29:07 +0000 Subject: [PATCH 058/109] [api] Fix, tests because of the new can_info permission --- docs/rest_api.rst | 2 +- flask_appbuilder/tests/test_api.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 260c94da3f..a1f8311624 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -378,7 +378,7 @@ define it almost like an MVC ``ModelView``. This class will expose the following +-----------------------------+-------------------------------------------------------+-----------------+--------+ | URL | Description | Permission Name | HTTP | +=============================+=======================================================+=================+========+ -| /_info | Returns info about the CRUD model and security | can_get | GET | +| /_info | Returns info about the CRUD model and security | can_info | GET | +-----------------------------+-------------------------------------------------------+-----------------+--------+ | / | Queries models data, receives args as Rison | can_get | GET | +-----------------------------+-------------------------------------------------------+-----------------+--------+ diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index fb44353723..3d7d762477 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -1080,6 +1080,7 @@ def test_info_permissions(self): expected_permissions = [ 'can_delete', 'can_get', + 'can_info', 'can_post', 'can_put', ] From b0c2b079b95df59ac8b7ac0c6a7d0a42052c0a5b Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 28 Mar 2019 10:59:19 +0000 Subject: [PATCH 059/109] [api] Fix, tests --- flask_appbuilder/tests/test_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 3d7d762477..5e79c9f7a0 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -129,7 +129,7 @@ class Model1ApiOrder(ModelRestApi): class Model1ApiRestrictedPermissions(ModelRestApi): datamodel = SQLAInterface(Model1) - base_permissions = ['can_get'] + base_permissions = ['can_get', 'can_info'] class Model1ApiFiltered(ModelRestApi): datamodel = SQLAInterface(Model1) @@ -1094,6 +1094,7 @@ def test_info_permissions(self): data = json.loads(rv.data.decode('utf-8')) expected_permissions = [ 'can_get', + 'can_info' ] eq_(sorted(data[API_PERMISSIONS_RES_KEY]), expected_permissions) From 68d1ab5d32cf82eef2322254e65b2a04b0d758f0 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 28 Mar 2019 11:15:27 +0000 Subject: [PATCH 060/109] [api] Fix, renamed RES/RIS keys for more cohesive convention --- docs/rest_api.rst | 14 +++++++------- flask_appbuilder/const.py | 12 ++++++------ flask_appbuilder/tests/test_api.py | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index a1f8311624..c3e3299d36 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -454,7 +454,7 @@ Now let's query our newly created Group:: { "description_columns": {}, - "include_columns": [ + "show_columns": [ "name" ], "label_columns": { @@ -598,8 +598,8 @@ layer to be able to render dynamically: First a birds eye view from the output of the **_info** endpoint:: { - "add_fields": [...], - "edit_fields": [...], + "add_columns": [...], + "edit_columns": [...], "filters": {...}, "permissions": [...] } @@ -717,7 +717,7 @@ The ``add_fields`` and ``edit_fields`` keys also render all possible values from related fields, using our *quickhowto* example:: { - "add_fields": [ + "add_columns": [ { "description": "", "label": "Gender", @@ -772,7 +772,7 @@ The response data structure is:: "id": "" "description_columnns": {}, "label_columns": {}, - "include_columns": [], + "show_columns": [], "result": {} } @@ -796,7 +796,7 @@ Our *curl* command will look like:: { "description_columns": {}, "id": "1", - "include_columns": [ + "show_columns": [ "name", "address" ], @@ -882,7 +882,7 @@ The response data structure is:: { "count": "ids": [ ... List of PK's ordered by result ... ], - "description_columnns": {}, + "description_columns": {}, "label_columns": {}, "list_columns": [ ... An ordered list of columns ...], "order_columns": [ ... List of columns that can be ordered ... ], diff --git a/flask_appbuilder/const.py b/flask_appbuilder/const.py index 88550b1411..ca7fa8ef87 100644 --- a/flask_appbuilder/const.py +++ b/flask_appbuilder/const.py @@ -139,9 +139,9 @@ API_ORDER_COLUMNS_RES_KEY = 'order_columns' API_LABEL_COLUMNS_RES_KEY = 'label_columns' API_LIST_COLUMNS_RES_KEY = 'list_columns' -API_SHOW_COLUMNS_RES_KEY = 'include_columns' -API_ADD_COLUMNS_RES_KEY = 'add_fields' -API_EDIT_COLUMNS_RES_KEY = 'edit_fields' +API_SHOW_COLUMNS_RES_KEY = 'show_columns' +API_ADD_COLUMNS_RES_KEY = 'add_columns' +API_EDIT_COLUMNS_RES_KEY = 'edit_columns' API_DESCRIPTION_COLUMNS_RES_KEY = 'description_columns' API_RESULT_RES_KEY = 'result' API_FILTERS_RES_KEY = 'filters' @@ -153,9 +153,9 @@ API_ORDER_COLUMNS_RIS_KEY = 'order_columns' API_LABEL_COLUMNS_RIS_KEY = 'label_columns' API_LIST_COLUMNS_RIS_KEY = 'list_columns' -API_SHOW_COLUMNS_RIS_KEY = 'include_columns' -API_ADD_COLUMNS_RIS_KEY = 'add_fields' -API_EDIT_COLUMNS_RIS_KEY = 'edit_fields' +API_SHOW_COLUMNS_RIS_KEY = 'show_columns' +API_ADD_COLUMNS_RIS_KEY = 'add_columns' +API_EDIT_COLUMNS_RIS_KEY = 'edit_columns' API_DESCRIPTION_COLUMNS_RIS_KEY = 'description_columns' API_FILTERS_RIS_KEY = 'filters' API_PERMISSIONS_RIS_KEY = 'permissions' diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 5e79c9f7a0..e51c2a1b2d 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -1026,7 +1026,7 @@ def test_info_fields_rel_field(self): 'value': "test{}".format(i) } ) - for rel_field in data['add_fields']: + for rel_field in data[API_ADD_COLUMNS_RES_KEY]: if rel_field['name'] == 'group': eq_(rel_field, expected_rel_add_field) From 718d5d97e075c43ca4ff90f0e740b9bbd6e7b561 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 28 Mar 2019 14:02:55 +0000 Subject: [PATCH 061/109] [api] New, add unique property to metadata of fields --- docs/rest_api.rst | 2 ++ flask_appbuilder/api.py | 14 ++++++++++++-- flask_appbuilder/models/sqla/interface.py | 2 +- flask_appbuilder/tests/test_api.py | 11 +++++++++-- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index c3e3299d36..7921cc056c 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -615,6 +615,7 @@ following data structure:: "label": "", "name": "", "required": true|false, + "unique": true|false, "type": "String|Integer|Related|RelatedList|...", "validate": [ ... list of validation methods ... ] "values" : [ ... optional with all possible values for a related field ... ] @@ -723,6 +724,7 @@ values from related fields, using our *quickhowto* example:: "label": "Gender", "name": "gender", "required": false, + "unique": false, "type": "Related", "values": [ { diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index c0ad2aacfd..c5ee95f078 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -651,18 +651,27 @@ def _column2field(self, datamodel, column, nested=True): many = True else: many = False - return fields.Nested(nested_schema, many=many, required=required) + field = fields.Nested(nested_schema, many=many, required=required) + field.unique = datamodel.is_unique(column) + return field # Handle bug on marshmallow-sqlalchemy #163 elif datamodel.is_relation(column): required = not datamodel.is_nullable(column) field = field_for(_model, column) field.required = required + field.unique = datamodel.is_unique(column) return field # Handle Enums elif datamodel.is_enum(column): required = not datamodel.is_nullable(column) enum_class = datamodel.list_columns[column].info.get('enum_class') - return EnumField(enum_class, dump_by=EnumField.VALUE, required=required) + field = EnumField(enum_class, dump_by=EnumField.VALUE, required=required) + field.unique = datamodel.is_unique(column) + return field + if not hasattr(getattr(_model, column), '__call__'): + field = field_for(_model, column) + field.unique = datamodel.is_unique(column) + return field def _model_schema_factory(self, columns, model=None, nested=True): """ @@ -1077,6 +1086,7 @@ def _get_field_info(self, field, filter_rel_field): ret['validate'] = [str(field.validate)] ret['type'] = field.__class__.__name__ ret['required'] = field.required + ret['unique'] = field.unique return ret def _get_fields_info(self, cols, model_schema, filter_rel_fields): diff --git a/flask_appbuilder/models/sqla/interface.py b/flask_appbuilder/models/sqla/interface.py index 478646ceb4..7c8b68672d 100644 --- a/flask_appbuilder/models/sqla/interface.py +++ b/flask_appbuilder/models/sqla/interface.py @@ -292,7 +292,7 @@ def is_nullable(self, col_name): def is_unique(self, col_name): try: - return self.list_columns[col_name].unique + return self.list_columns[col_name].unique == True except: return False diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index e51c2a1b2d..3337923ae9 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -507,7 +507,7 @@ def test_get_item_1m_field(self): def test_get_item_mm_field(self): """ - REST Api: Test get item with N-N releted field + REST Api: Test get item with N-N related field """ client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) @@ -964,13 +964,16 @@ def test_info_fields(self): 'description': 'Field Integer', 'label': 'Field Integer', 'name': 'field_integer', - 'required': False, 'type': 'Integer' + 'required': False, + 'unique': False, + 'type': 'Integer' }, { 'description': 'Field Float', 'label': 'Field Float', 'name': 'field_float', 'required': False, + 'unique': False, 'type': 'Float' }, { @@ -978,6 +981,7 @@ def test_info_fields(self): 'label': 'Field String', 'name': 'field_string', 'required': True, + 'unique': True, 'type': 'String', 'validate': [''] }, @@ -986,6 +990,7 @@ def test_info_fields(self): 'label': 'Field Date', 'name': 'field_date', 'required': False, + 'unique': False, 'type': 'Date' } ] @@ -1016,6 +1021,7 @@ def test_info_fields_rel_field(self): 'label': 'Group', 'name': 'group', 'required': True, + 'unique': False, 'type': 'Related', 'values': [] } @@ -1049,6 +1055,7 @@ def test_info_fields_rel_filtered_field(self): 'label': 'Group', 'name': 'group', 'required': True, + 'unique': False, 'type': 'Related', 'values': [ { From 9bd37c8ccfd99f95ca08466c05afe5a01bb25e0e Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 28 Mar 2019 14:55:35 +0000 Subject: [PATCH 062/109] [api] Tests for allow_browser_login --- flask_appbuilder/security/decorators.py | 23 +++--- flask_appbuilder/tests/test_api.py | 93 +++++++++++++++++++++---- 2 files changed, 94 insertions(+), 22 deletions(-) diff --git a/flask_appbuilder/security/decorators.py b/flask_appbuilder/security/decorators.py index c2e377d9ad..8f687de47b 100644 --- a/flask_appbuilder/security/decorators.py +++ b/flask_appbuilder/security/decorators.py @@ -48,26 +48,33 @@ def _protect(f): def wraps(self, *args, **kwargs): permission_str = "{}{}".format(PERMISSION_PREFIX, f._permission_name) + class_permission_name = self.__class__.__name__ if current_app.appbuilder.sm.is_item_public( permission_str, - self.__class__.__name__ + class_permission_name ): return f(self, *args, **kwargs) if not (self.allow_browser_login or allow_browser_login): verify_jwt_in_request() if current_app.appbuilder.sm.has_access( permission_str, - self.__class__.__name__ + class_permission_name ): return f(self, *args, **kwargs) - else: - log.warning( - LOGMSG_ERR_SEC_ACCESS_DENIED.format( + elif (self.allow_browser_login or allow_browser_login): + verify_jwt_in_request() + if current_app.appbuilder.sm.has_access( permission_str, - self.__class__.__name__ - ) + class_permission_name + ): + return f(self, *args, **kwargs) + log.warning( + LOGMSG_ERR_SEC_ACCESS_DENIED.format( + permission_str, + class_permission_name ) - return self.response_401() + ) + return self.response_401() f._permission_name = permission_str return functools.update_wrapper(wraps, f) return functools.update_wrapper(_protect, allow_browser_login) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 3337923ae9..909dc3f951 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -64,6 +64,7 @@ def setUp(self): self.app.config['SECRET_KEY'] = 'thisismyscretkey' self.app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False self.app.config['FAB_API_MAX_PAGE_SIZE'] = MAX_PAGE_SIZE + self.app.config['WTF_CSRF_ENABLED'] = False self.db = SQLA(self.app) self.appbuilder = AppBuilder(self.app, self.db.session) @@ -84,6 +85,9 @@ class Model1Api(ModelRestApi): 'field_string': 'Field String' } + self.model1api = Model1Api + self.appbuilder.add_view_no_menu(Model1Api) + class Model1ApiFieldsInfo(Model1Api): datamodel = SQLAInterface(Model1) add_columns = [ @@ -97,6 +101,9 @@ class Model1ApiFieldsInfo(Model1Api): 'field_integer' ] + self.model1apifieldsinfo = Model1ApiFieldsInfo + self.appbuilder.add_view_no_menu(Model1ApiFieldsInfo) + class Model1FuncApi(ModelRestApi): datamodel = SQLAInterface(Model1) list_columns = [ @@ -112,6 +119,9 @@ class Model1FuncApi(ModelRestApi): 'field_string': 'Field String' } + self.model1funcapi = Model1Api + self.appbuilder.add_view_no_menu(Model1FuncApi) + class Model1ApiExcludeCols(ModelRestApi): datamodel = SQLAInterface(Model1) list_exclude_columns = [ @@ -123,14 +133,20 @@ class Model1ApiExcludeCols(ModelRestApi): edit_exclude_columns = list_exclude_columns add_exclude_columns = list_exclude_columns + self.appbuilder.add_view_no_menu(Model1ApiExcludeCols) + class Model1ApiOrder(ModelRestApi): datamodel = SQLAInterface(Model1) base_order = ('field_integer', 'desc') + self.appbuilder.add_view_no_menu(Model1ApiOrder) + class Model1ApiRestrictedPermissions(ModelRestApi): datamodel = SQLAInterface(Model1) base_permissions = ['can_get', 'can_info'] + self.appbuilder.add_view_no_menu(Model1ApiRestrictedPermissions) + class Model1ApiFiltered(ModelRestApi): datamodel = SQLAInterface(Model1) base_filters = [ @@ -138,23 +154,22 @@ class Model1ApiFiltered(ModelRestApi): ['field_integer', FilterSmaller, 4] ] + self.appbuilder.add_view_no_menu(Model1ApiFiltered) + class ModelWithEnumsApi(ModelRestApi): datamodel = SQLAInterface(ModelWithEnums) + self.appbuilder.add_view_no_menu(ModelWithEnumsApi) + + class Model1BrowserLogin(ModelRestApi): + datamodel = SQLAInterface(Model1) + allow_browser_login = True + + self.appbuilder.add_view_no_menu(Model1BrowserLogin) + class ModelMMApi(ModelRestApi): datamodel = SQLAInterface(ModelMMParent) - self.model1api = Model1Api - self.appbuilder.add_view_no_menu(Model1Api) - self.model1funcapi = Model1Api - self.appbuilder.add_view_no_menu(Model1FuncApi) - self.model1apifieldsinfo = Model1ApiFieldsInfo - self.appbuilder.add_view_no_menu(Model1ApiFieldsInfo) - self.appbuilder.add_view_no_menu(Model1ApiOrder) - self.appbuilder.add_view_no_menu(Model1ApiFiltered) - self.appbuilder.add_view_no_menu(Model1ApiExcludeCols) - self.appbuilder.add_view_no_menu(ModelWithEnumsApi) - self.appbuilder.add_view_no_menu(Model1ApiRestrictedPermissions) self.appbuilder.add_view_no_menu(ModelMMApi) class Model2Api(ModelRestApi): @@ -166,6 +181,9 @@ class Model2Api(ModelRestApi): 'group' ] + self.model2api = Model2Api + self.appbuilder.add_view_no_menu(Model2Api) + class Model2ApiFilteredRelFields(ModelRestApi): datamodel = SQLAInterface(Model2) list_columns = [ @@ -182,10 +200,9 @@ class Model2ApiFilteredRelFields(ModelRestApi): } edit_query_rel_fields = add_query_rel_fields - self.model2api = Model2Api - self.appbuilder.add_view_no_menu(Model2Api) self.model2apifilteredrelfields = Model2ApiFilteredRelFields self.appbuilder.add_view_no_menu(Model2ApiFilteredRelFields) + role_admin = self.appbuilder.sm.find_role('Admin') self.appbuilder.sm.add_user( USERNAME, @@ -260,6 +277,16 @@ def login(self, client, username, password): except: return rv + def browser_login(self, client, username, password): + # Login with default admin + rv = client.post('/login/', data=dict( + username=username, + password=password + ), follow_redirects=True) + + def browser_logout(self, client): + return client.get('/logout/') + def test_auth_login(self): """ REST Api: Test auth login @@ -291,13 +318,49 @@ def test_auth_login_bad(self): ) eq_(rv.status_code, 400) + def test_auth_authorization_browser(self): + """ + REST Api: Test authorization with browser login + """ + client = self.app.test_client() + rv = self.browser_login(client, USERNAME, PASSWORD) + # Test access with browser login + uri = 'api/v1/model1browserlogin/1' + rv = client.get( + uri + ) + eq_(rv.status_code, 200) + # Test unauthorized access with browser login + uri = 'api/v1/model1api/1' + rv = client.get( + uri + ) + eq_(rv.status_code, 401) + # Test access wihout cookie or JWT + rv = self.browser_logout(client) + # Test access with browser login + uri = 'api/v1/model1browserlogin/1' + rv = client.get( + uri + ) + eq_(rv.status_code, 401) + # Test access with JWT but without cookie + token = self.login(client, USERNAME, PASSWORD) + uri = 'api/v1/model1browserlogin/1' + rv = self.auth_client_get( + client, + token, + uri + ) + eq_(rv.status_code, 200) + def test_auth_authorization(self): """ REST Api: Test auth base limited authorization """ client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) - + # Test unauthorized DELETE pk = 1 uri = 'api/v1/model1apirestrictedpermissions/{}'.format(pk) rv = self.auth_client_delete( @@ -306,6 +369,7 @@ def test_auth_authorization(self): uri ) eq_(rv.status_code, 401) + # Test unauthorized POST item = dict( field_string="test{}".format(MODEL1_DATA_SIZE+1), field_integer=MODEL1_DATA_SIZE+1, @@ -320,6 +384,7 @@ def test_auth_authorization(self): item ) eq_(rv.status_code, 401) + # Test unauthorized GET uri = 'api/v1/model1apirestrictedpermissions/1' rv = self.auth_client_get( client, From f471adc0c125d91249254037ef245d042b87ae41 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 28 Mar 2019 14:57:55 +0000 Subject: [PATCH 063/109] [api] New, allow_browser_login option to enable cookie sessions --- flask_appbuilder/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 909dc3f951..9a87b3326b 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -320,7 +320,7 @@ def test_auth_login_bad(self): def test_auth_authorization_browser(self): """ - REST Api: Test authorization with browser login + REST Api: Test auth with browser login """ client = self.app.test_client() rv = self.browser_login(client, USERNAME, PASSWORD) From 8dc7ddba9fef7fb97cbee275332b5d06bd3feec2 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 28 Mar 2019 17:39:54 +0000 Subject: [PATCH 064/109] [api] Fix, Py2 and Py3 compat for update_wrapper --- examples/quickhowto/app/models.py | 16 ++++++++++++---- examples/quickhowto/app/views.py | 10 ++++------ examples/quickhowto/config.py | 3 ++- flask_appbuilder/api.py | 18 ++++++++++++------ flask_appbuilder/security/decorators.py | 2 +- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/examples/quickhowto/app/models.py b/examples/quickhowto/app/models.py index 0f523a2e14..23a947b209 100644 --- a/examples/quickhowto/app/models.py +++ b/examples/quickhowto/app/models.py @@ -1,5 +1,5 @@ import datetime -from sqlalchemy import Column, Integer, String, ForeignKey, Date +from sqlalchemy import Column, Integer, String, ForeignKey, Date, Enum from sqlalchemy.orm import relationship from flask_appbuilder import Model from marshmallow import Schema, fields, ValidationError, post_load, pre_load @@ -34,11 +34,18 @@ class ContactGroupSchema(Schema): class Gender(Model): id = Column(Integer, primary_key=True) - name = Column(String(50), unique = True, nullable=False) + name = Column(String(50), unique=True, nullable=False) def __repr__(self): return self.name +import enum + + +class GenderEnum(enum.Enum): + male = 'Male' + female = 'Female' + class Contact(Model): id = Column(Integer, primary_key=True) @@ -49,8 +56,9 @@ class Contact(Model): personal_celphone = Column(String(20)) contact_group_id = Column(Integer, ForeignKey('contact_group.id'), nullable=False) contact_group = relationship("ContactGroup") - gender_id = Column(Integer, ForeignKey('gender.id'), nullable=False) - gender = relationship("Gender") + #gender_id = Column(Integer, ForeignKey('gender.id'), nullable=False) + #gender = relationship("Gender") + gender = Column(Enum(GenderEnum), nullable=False, info={"enum_class": GenderEnum}) def __repr__(self): return self.name diff --git a/examples/quickhowto/app/views.py b/examples/quickhowto/app/views.py index bd89e36a9b..cf548f7090 100644 --- a/examples/quickhowto/app/views.py +++ b/examples/quickhowto/app/views.py @@ -60,16 +60,15 @@ class GroupModelView(ModelView): class GroupModelRestApi(ModelRestApi): resource_name = 'group' - add_model_schema = GroupCustomSchema() - edit_model_schema = GroupCustomSchema() datamodel = SQLAInterface(ContactGroup) class ContactModelRestApi(ModelRestApi): resource_name = 'contact' + allow_browser_login = True datamodel = SQLAInterface(Contact) - list_columns = ['name', 'contact_group'] - base_filters = [['contact_group.name', FilterStartsWith, 'F']] + #list_columns = ['name', 'some_function'] + #base_filters = [['contact_group.name', FilterStartsWith, 'F']] #list_model_schema = ContactSchema() #base_filters = [['name', FilterStartsWith, 'a']] #add_query_rel_fields = { @@ -80,7 +79,7 @@ class ContactModelRestApi(ModelRestApi): #} #list_columns = ['name', 'address', 'personal_celphone'] - #base_order = ('name', 'desc') + base_order = ('name', 'desc') #list_exclude_columns = ['gender', 'contact_group_id','gender_id', 'id'] #show_exclude_columns = ['name'] @@ -149,4 +148,3 @@ def after_request(response): response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') return response - diff --git a/examples/quickhowto/config.py b/examples/quickhowto/config.py index 70497d5e11..82129cd3ae 100644 --- a/examples/quickhowto/config.py +++ b/examples/quickhowto/config.py @@ -16,7 +16,7 @@ SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db') #SQLALCHEMY_DATABASE_URI = 'mysql://username:password@mysqlserver.local/quickhowto' #SQLALCHEMY_DATABASE_URI = 'postgresql://scott:tiger@localhost:5432/myapp' -#SQLALCHEMY_ECHO = True +SQLALCHEMY_ECHO = True SQLALCHEMY_POOL_RECYCLE = 3 BABEL_DEFAULT_LOCALE = 'en' @@ -36,6 +36,7 @@ } FAB_API_MAX_PAGE_SIZE = 30 +#FAB_API_SHOW_STACKTRACE = True #------------------------------ # GLOBALS FOR GENERAL APP's diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index c5ee95f078..12ad1498d2 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -851,6 +851,9 @@ def put(self, pk): if not item: return self.response_404() try: + # MERGE + for _col in self.add_columns: + print("COL!!! {}={}".format(_col, getattr(item, _col))) item = self.edit_model_schema.load(request.json, instance=item) except ValidationError as err: return self.response(400, **{'message': err.messages}) @@ -894,7 +897,7 @@ def merge_label_columns(self, response, **kwargs): _show_columns = self.show_columns response[API_LABEL_COLUMNS_RES_KEY] = self._label_columns_json(_show_columns) - def merge_include_columns(self, response, **kwargs): + def merge_show_columns(self, response, **kwargs): _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) if _pruned_select_cols: response[API_SHOW_COLUMNS_RES_KEY] = _pruned_select_cols @@ -919,14 +922,17 @@ def merge_list_columns(self, response, **kwargs): def merge_order_columns(self, response, **kwargs): _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) - response[API_ORDER_COLUMNS_RES_KEY] = [ - order_col - for order_col in self.order_columns if order_col in _pruned_select_cols - ] + if _pruned_select_cols: + response[API_ORDER_COLUMNS_RES_KEY] = [ + order_col + for order_col in self.order_columns if order_col in _pruned_select_cols + ] + else: + response[API_ORDER_COLUMNS_RES_KEY] = self.order_columns @rison @merge_response_func(merge_label_columns, API_LABEL_COLUMNS_RIS_KEY) - @merge_response_func(merge_include_columns, API_SHOW_COLUMNS_RIS_KEY) + @merge_response_func(merge_show_columns, API_SHOW_COLUMNS_RIS_KEY) @merge_response_func(merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY) def _get_item(self, pk, **kwargs): item = self.datamodel.get(pk, self._base_filters) diff --git a/flask_appbuilder/security/decorators.py b/flask_appbuilder/security/decorators.py index 8f687de47b..f93989086b 100644 --- a/flask_appbuilder/security/decorators.py +++ b/flask_appbuilder/security/decorators.py @@ -77,7 +77,7 @@ def wraps(self, *args, **kwargs): return self.response_401() f._permission_name = permission_str return functools.update_wrapper(wraps, f) - return functools.update_wrapper(_protect, allow_browser_login) + return _protect def has_access(f): From 6cc96847fffcca9ec4a1cb3eec75c02a48e9c3f0 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 28 Mar 2019 19:14:49 +0000 Subject: [PATCH 065/109] [examples] CRUD REST API --- examples/crud_rest_api/NAMES.DIC | 27607 ++++++++++++++++ examples/crud_rest_api/README.rst | 18 + examples/crud_rest_api/app/__init__.py | 28 + examples/crud_rest_api/app/models.py | 47 + .../translations/pt/LC_MESSAGES/messages.mo | Bin 0 -> 516 bytes .../translations/pt/LC_MESSAGES/messages.po | 27 + examples/crud_rest_api/app/views.py | 45 + examples/crud_rest_api/babel/babel.cfg | 3 + examples/crud_rest_api/babel/messages.pot | 27 + examples/crud_rest_api/config.py | 67 + examples/crud_rest_api/run.py | 3 + examples/crud_rest_api/testdata.py | 72 + examples/quickhowto/app/models.py | 46 +- examples/quickhowto/app/views.py | 105 +- examples/quickhowto/config.py | 5 +- flask_appbuilder/__init__.py | 5 +- flask_appbuilder/api.py | 5 +- 17 files changed, 28002 insertions(+), 108 deletions(-) create mode 100644 examples/crud_rest_api/NAMES.DIC create mode 100644 examples/crud_rest_api/README.rst create mode 100644 examples/crud_rest_api/app/__init__.py create mode 100644 examples/crud_rest_api/app/models.py create mode 100644 examples/crud_rest_api/app/translations/pt/LC_MESSAGES/messages.mo create mode 100644 examples/crud_rest_api/app/translations/pt/LC_MESSAGES/messages.po create mode 100644 examples/crud_rest_api/app/views.py create mode 100644 examples/crud_rest_api/babel/babel.cfg create mode 100644 examples/crud_rest_api/babel/messages.pot create mode 100644 examples/crud_rest_api/config.py create mode 100644 examples/crud_rest_api/run.py create mode 100644 examples/crud_rest_api/testdata.py diff --git a/examples/crud_rest_api/NAMES.DIC b/examples/crud_rest_api/NAMES.DIC new file mode 100644 index 0000000000..b7266ad644 --- /dev/null +++ b/examples/crud_rest_api/NAMES.DIC @@ -0,0 +1,27607 @@ +aaccf +aalders +aaren +aarika +aaron +aartjan +aasen +ab +abacus +abadines +abagael +abagail +abahri +abasolo +abazari +abba +abbai +abbas +abbatant +abbate +abbe +abbey +abbi +abbie +abbot +abbott +abby +abbye +abdalla +abdallah +abdel +abdel-az +abdel-ma +abdel-ra +abdel-sa +abdelazi +abdelmad +abdelrah +abdelran +abdelsal +abderrao +abderraz +abdi +abdo +abdollah +abdolrah +abdou +abdrani +abdul +abdul-az +abdul-ma +abdul-no +abdul-ra +abdul-sa +abdulazi +abdulla +abdullah +abdulmad +abdulrah +abdulran +abdulsal +abdur +abe +abedi +abel +abelard +abell +abella +abellera +abello +abelow +abernath +aberneth +abeu +abey +abhay +abhijit +abi-aad +abid +abie +abigael +abigail +abigale +abike +abner +abou-arr +abou-ezz +aboul-ma +aboussou +abovyan +abra +abraham +abrahan +abrahim +abram +abramo +abrams +abran +abrar +absi +abu +abul +access +accounti +acelvari +achal +achamma +acharyya +achcar +achille +achkar +achmad +ackaouy +acker +acklin +ackwood +acree +acres +acs +action +actionte +acton +aczel +ad +ada +adah +adahm +adair +adal +adaline +adam +adamczyk +adamkows +adamo +adamowic +adams +adamski +adamson +adamyk +adan +adara +adcock +adcox +adda +addetia +addi +addia +addie +addison +addona +addons +addy +ade +adebayo +adel +adela +adelaida +adelaide +adelbert +adele +adelheid +adelia +adelice +adelina +adelind +adeline +adella +adelle +adena +adeney +adeniyi +aderhold +adey +adham +adhem +adi +adiana +adib +adie +adil +adimari +adina +aditya +adjangba +adkinson +adlai +adler +adlin +admad +admin +admin-mt +administ +adnan +adnane +ado +adolf +adolfie +adolph +adolphe +adolpho +adolphus +adora +adore +adoree +adornato +adorne +adorno +adrea +adri +adria +adriaan +adriaans +adriaens +adrian +adriana +adriane +adrianna +adrianne +adriano +adrie +adrien +adriena +adriene +adrienne +adrion +advance +ae +aeinstei +aeriel +aeriela +aeriell +aery +afaq +afif +afke +afkham +afkham-e +afo +afton +afzal +ag +agace +agam +agarwal +agata +agatha +agathe +agen +agenia +aggarwal +aggi +aggie +aggregat +aggy +aghi +aghili +agily +agna +agnar +agnella +agnes +agnese +agnesse +agneta +agnew +agnihotr +agnola +agostino +agosto +agretha +aguiar +aguie +aguilar +aguinsky +aguirre +aguistin +aguste +agustin +ahad +aharon +ahbeng +ahdieh +ahlberg +ahlers +ahluwali +ahmad +ahmadi +ahmed +ahmet +ahn +ai-mei +ai-tsung +aida +aidan +aidarous +aideen +aiden +aigneis +aihua +aija +aiken +aila +ailbert +aile +ailee +aileen +ailene +ailey +aili +ailina +ailis +ailsun +ailyn +aiman +aime +aimee +aimei +aimil +aimone +aindrea +ainslee +ainsley +ainslie +ainswort +air +aisha +aitken +aitsung +ajay +ajersch +ajeya +ajit +ajmal +ajoy +akai +akbar +akbas +akemi +akens +akers +akhavan +akhil +akhtar +akihiko +akim +akin +akinniyi +akio +akira +akita +akkerman +akram +akrawi +aksel +akshay +akyurekl +al +al bud +al-basi +al-tarab +aladanga +aladin +alain +alaine +alair +alameda +alan +alana +alanah +aland +alane +alanis +alanna +alano +alanoly +alanson +alanturi +alard +alaric +alary +alasdair +alastair +alasteir +alaster +alavi +alayne +alb +alba +albea +albeon +alber +alberik +albers +alberse +albert +alberta +albertei +albertin +alberto +alberts +alberty +albery +albie +albina +albiston +albrecht +albright +albritto +albtenta +alburger +alcantar +alcindor +alcock +alcott +alden +alderdic +aldhizer +aldin +aldis +aldo +aldon +aldous +aldric +aldrich +aldridge +aldus +aldwin +aldyn +alec +alecia +aleda +aleece +aleen +alegre +aleinste +alejandr +alejoa +aleke +aleksand +aleksic +alena +alene +aleong +alese +alessand +aleta +alethea +alev +alex +alexa +alexan +alexande +alexandr +alexei +alexi +alexia +alexina +alexine +alexio +alexis +alexon +alexson +alf +alfaro +alfi +alfie +alfons +alfonse +alfonso +alfonzo +alford +alfred +alfreda +alfredo +alfy +algernon +algie +algimant +algood +alguire +ali +alia +alic +alica +alice +alicea +alicia +alick +alida +alidia +alidina +alie +alika +alikee +alikhan +alina +aline +alink +alioto +alireza +alis +alisa +alisande +alisha +alison +alissa +alistair +alister +alisun +alix +aliza +alka +alkarim +alkire +all the +alla +allahdin +allahyar +allam +allaman +allan +allard +allaway +allaye-c +allayne +alleen +allegra +allen +allene +alles +alleva +alley +alleyn +alleyne +allgood +alli +allianor +allida +allie +allin +allina +allis +allisan +allison +allissa +allister +allistir +allix +allman +allsun +allwork +allx +ally +allyce +allyn +allys +allyson +alma +almeda +almeddah +almena +almendar +almeria +almerind +almeta +almira +almire +almon +alms +alnoor +aloi +aloin +aloise +aloisia +alok +alomari +alon +alonso +alonzo +alora +aloysia +aloysius +alp +alparsla +alperovi +alphard +alphen +alphonse +alphonso +alpine +alred +alric +alsaleh +alshabou +alsop +alspaugh +alstine +alston +alswiti +alta +altadonn +altay +alteen +altekar +alten +alternat +althea +altherr +alting-m +altman +altmann +alturing +aluin +aluino +alva +alvan +alvarez +alvaro +alvean +alvera +alverta +alvi +alvie +alvin +alvina +alvinia +alvino +alvira +alvis +alvy +alwin +alwyn +aly +alyce +alyda +alynn +alyosha +alyre +alys +alysa +alyse +alysia +alyson +alyss +alyssa +alzofon +amabel +amabelle +amadeus +amalea +amalee +amaleta +amalia +amalie +amalita +amalle +amalu +amand +amanda +amandi +amandie +amandip +amando +amandy +amant +amar +amara +amarendr +amargo +amarjit +amarsi +amarth +amata +amato +amavisca +ambach +amber +amberly +amble +ambler +ambroise +ambros +ambrose +ambrosi +ambrosio +ambrosiu +ambur +amby +amda +ame +amedeo +ameen +amelia +amelie +amelina +ameline +amelita +amelkar +amenta +america +amerigo +amery +amgad +ami +amick +amie +amigo +amii +amil +amin +amina +amini +aminuddi +aminzade +amiot +amir +amit +amitabh +amitava +amitie +amity +amiy +amjad +amlani +ammamari +ammar +ammiel +amnish +amnon +amol +amorim +amory +amos +amott +amour +amouzgar +amparo +amr +amrik +amril +amrish +amstutz +amu +amundsen +amy +amye +an +an-bin +an-son +ana +anabal +anabel +anabella +anabelle +anader +analiese +analise +anallese +anallise +anamary +anand +ananda +anandaro +ananmala +anant +ananth +anantha +ananyo +anar +anastasi +anastass +anatol +anatola +anatole +anatoli +anatollo +anatoly +anaya +anbin +ancel +ancell +anchia +anconeta +anctil +anda +andaree +andee +andeee +ander +anderea +anderer +anders +andersen +anderson +anderton +andi +andie +andiyono +andonis +andra +andrade +andras +andrassy +andre +andrea +andreana +andreas +andrease +andreato +andree +andrei +andrej +andrejs +andres +andress +andrew +andrews +andrey +andria +andriana +andric +andries +andriett +andris +andromac +andros +andrukat +andrus +andrusia +andruzzi +andrzej +andy +aneeta +aneko +anestass +anet +anett +anetta +anette +ange +angel +angela +angelako +angele +angeles +angeli +angelia +angelica +angelico +angelie +angeliek +angelika +angelina +angeline +angeliqu +angelita +angell +angelle +angelo +angerer +angermey +angie +angil +anglin +angobald +angus +angustia +angvall +angy +anh +anhorn +anhtuan +ania +anibal +anica +anika +aniko +anil +anila +anindita +anirban +anissa +anita +anitra +aniya +anja +anjali +anjanett +anje +anjela +anjli +anke +anker +anki +ankie +ankur +anky +ann +ann-hoon +ann-lorr +ann-mari +anna +anna-dia +anna-mar +annab +annabal +annabel +annabela +annabell +annable +annadian +annalea +annalee +annalies +annalisa +annalise +annamari +annamay +annarbor +annard +annas +anne +anne mar +anne-cor +anne-lis +anne-mar +annecori +anneke +annelies +annelise +annemari +annemie +annet +annetta +annette +anni +annibale +annice +annick +annie +annika +annis +annise +annissa +annmaria +annmarie +annnora +annora +annunzia +anny +anolik +anoop +anouk +anoushir +ans +ansar +ansel +ansell +anselm +anselma +anshel +ansley +anson +ansorger +anstead +anstett +anstice +ansys +antai +antanas +antanina +anthe +anthea +anthia +anthiath +anthonis +anthony +antin +antinucc +antkowia +antle +antoft +antoine +antoinet +anton +anton-ph +antonare +antone +antonell +antonett +antoni +antonia +antonie +antoniet +antonin +antonina +antonino +antonio +antonios +antonius +antons +antony +antti +antuan +antworth +anu +anup +anupam +anurag +anver +anvradha +anwar +any +anya +anzarout +anzures +aoki +aparicio +aparna +aphrodit +api-ecm +apiruksa +apollo +apostolo +appell +appenzel +applebau +applegar +appleton +appleyar +applicat +applicon +appoloni +appuglie +apriel +april +aprilett +aprill +apryle +apter +apurba +apurve +ara +arabadji +arabel +arabela +arabele +arabella +arabelle +aragon +aragorn +arai +araldo +aramideh +arana +arash +aravamud +arbel +arbenz +arbo +arbuckle +arch +archaimb +archamba +archana +archer +archibal +archibol +archie +archy +arco +arcouet +ard +arda +ardath +ardavan +ardeen +ardelia +ardelis +ardella +ardelle +arden +ardene +ardenia +ardie +ardiel +ardine +ardis +ardisj +ardith +ardizone +ardoin +ardra +ardyce +ardys +ardyth +aref +areg +arel +arellano +arend +arens +ares +aretha +areu +argento +argyriou +ari +ariadne +ariana +arias +aribindi +aric +aridatha +arie +ariel +ariela +ariella +arielle +arif +arin +arina +arine +ario +aris +aristide +aristotl +arjun +arkady +arkestei +arko +arlan +arlana +arlee +arleen +arlen +arlena +arlene +arles +arleta +arlette +arley +arleyne +arlie +arliene +arlin +arlina +arlinda +arline +arluene +arly +arlyn +arlyne +arman +armand +armande +armando +armbrust +armelia +armelle +armenaki +armenta +armentro +armes +armida +armijo +armin +armine +armitage +armolavi +armour +armstead +armstron +arn +arnaldo +arnauld +arnav +arne +arnett +arney +arni +arnie +arnis +arno +arnold +arnoldo +arnon +arnone +arnott +arnuad +arny +aroldo +aron +arona +aronovic +aronson +aronstam +arora +arpin +arpita +arrgh +arri +arro +arron +arsavir +arsena +arsenaul +arsene +arseneau +arshad +art +artair +arte +artemas +artemis +artemus +arther +arthur +artie +artiller +arto +artola +arts +artspssa +artur +arturo +artus +artuso +arty +artzer +arul +arumugam +arun +aruna +arunacha +arv +arvid +arvie +arvin +arvind +arvy +arwakhi +arya +aryavong +aryn +arzu +asa +asad +asan +asawa +asbill +asbjorn +asce +ascott +asdel +ase +asfazado +asghar +asgharza +ash +ashalata +ashar +ashbee +ashberry +ashbey +ashby +ashdown +ashely +asher +ashfaq +ashford +ashia +ashien +ashil +ashima +ashis +ashla +ashlan +ashlee +ashleigh +ashlen +ashley +ashli +ashlie +ashlin +ashly +ashmore +ashok +ashoka +ashrae +ashraf +ashruf +ashton +ashu +ashurkof +ashutosh +ashwin +ashwood- +ashworth +asia +asif +asing +asistore +askins +askold +asmar +asme +asnat +asops +asprer +asquin +assaad +assaf +asselin +assenza +assistan +associat +astaire +astalos +astle +astley +aston +astor +astorino +astra +astrid +astrix +aswini +atalanta +atalla +atcheson +atchison +atef +athalie +athanasi +athanass +athar +athena +athene +athony +athwal +atindra +atique +atkins +atkinson +atl +atl-sale +atlanta +atlante +atlantic +atmane +atoui +atp +atpco +atprs +atputhar +atrc +atsuo +atsushi +atta +attaie +attanasi +attarchi +attard +attaway +atte +attenbor +atteridg +attfield +attilio +attilla +atul +atwater +atwell +atwell-b +au +au-yang +au-yeung +aube +auberon +aubert +auberta +aubin +aubine +aubree +aubrette +aubrey +aubrie +aubry +aubuchon +aucoin +aud +audet +audette +audi +audie +audivox +audra +audre +audrean +audrey +audrie +audrienn +audry +audrye +audy +auerbach +augeri +augie +august +augusta +auguste +augustin +augusto +augustus +augy +auker +aula +aulakh +auld +ault +aumoine +aundrea +aunon +aura +aurea +aurel +aurelea +aurelia +aurelie +aurelius +auria +aurie +aurilia +auriol +aurlie +auro +auroora +aurora +aurore +aurthur +ausley +austen +austin +austina +austine +australi +auth +auto +auton +autoquot +auyeung +ava +avard +avaz +ave +avedis +aveline +avellane +averardo +averell +averett +averette +averil +averill +aversa +avery +averyl +avictor +avie +avigdor +avilez +avinash +avirett +avis +aviva +avivah +avra +avram +avril +avrit +avrom +avtar +awadalla +awadia +awan +awano +ax +axberg +axe +axel +ayako +ayandeh +ayao +ayaz +aybars +ayda +aydin +ayers +ayles +aylmar +aylmer +aylwin +aymer +ayn +ayodele +ayotte +ayoubzad +ayoup +ayrault +ayre +ayres +ayscue +ayse +ayukawa +aywie +ayyuce +azad +azam +azar +azari +azarshah +azer +azevedo +azhar +azim +aziz +azizuddi +azmak +azmeena +azmina +azra +azuma +azzuolo +ba +baab +baader +bab +baba +bababunm +babak +babalola +babar +babara +babasaki +babatund +babb +babbage +babbette +babbie +babcock +baber +babette +babin +babineau +babione +babita +babs +babu +baby +babyak +baccari +bacchioc +bacchus +bacciagl +bach +bachecon +bachelu +bachewic +bachitta +bachmann +bachner +bachynsk +backshal +bacon +baddeley +badelt +badenoch +badger +badjari +badmingt +badowski +badri +badza +bae +baenzige +baer +baerg +baets +bagetako +bagg +baggerma +baghdadi +bagi +bagnato +bagshaw +bagwell +baha +bahaa +bahadir +baheya +bahgat +bahia +bahl +bahman +bahoric +bahram +bail +bailetti +bailey +bailie +baillarg +baillie +bailloux +baily +bain +bainer +baines +bains +bainton +baird +bajada +bajpeyi +bakay +bakel +baker +baker-gr +bakhach +bakkum +bakoury +bal +balaban +balabani +balachan +balaji +balakris +balanger +balascak +balasing +balbir +balcom +bald +balderst +baldev +baldock +baldridg +balduin +baldwin +bale +bales +balfour +balgalvi +baljinde +balkenho +balkisso +ballanti +ballard +ballarte +ballinge +ballios +ballou +ballyk +balmer +balog +balogh +balraj +balsas +balser +balter +baltodan +balutis +balvinde +balwinde +bam +bambach +bambang +bambi +bambie +bamby +bame +bamfo +ban +banaei +bancroft +bandel +banens +banerd +banerjee +banez +banfalvi +bang +bangert +bangia +banh +banigan +banik +bank +bankhead +banks +banky +bannai +bannan +bannard +banniste +bansal +banu +banville +bao +baominh +baquero +bar +barabash +baragar +barakat +baran +barb +barba +barbabas +barbabra +barbara +barbara- +barbaraa +barbary +barbe +barbeau +barbee +barberen +barbette +barbey +barbi +barbie +barbour +barbra +barby +barclay +barcza +bard +barde +bardsley +bareham +barel +barenie +barentse +barfield +barham +bari +baribeau +baril +baris +barker +barkhous +barkley +barkwill +barlas +barlow +barn +barnabas +barnabe +barnaby +barnard +barnebas +barnes +barnett +barney +barnhard +barnhart +barnhill +barnhous +barnicke +barnie +barnwell +barny +barolet +baron +barr +barraclo +barrass +barrell +barret +barrett +barreyre +barri +barrie +barrient +barriere +barringt +barrio +barris +barritt +barron +barrows +barry +barsch +barsha +barsky +barsony +barstow +barszcze +bart +bartel +barth +barthel +barthole +bartholo +bartie +bartkows +bartlet +bartlett +bartley +bartolem +bartolom +bartoluc +barton +bartosze +bartra +bartram +bartush +barty +bartz +baruk +barwikow +bary +baryram +basa +basco +bascombe +base +basheer +bashton +bashyam +basia +basil +basile +basilio +basilius +basinger +baskaran +baskervi +baskin +basladyn +basmadji +basnett +bason +basrur +bassam +bassem +basser +bassett +bassigna +bassil +basta +bastani +bastarac +bastian +bastien +basu +bat +batcheld +batchelo +batchoun +bateman +bates +batholom +bathrick +bathsheb +batsheva +batson +battersb +battersh +battisto +batura +baudais +baudoin +bauer +baughan +baugnon +baulch +baum +baumann +baumberg +baumert +bautista +bawek +bawn +bax +baxie +baxter +baxy +bay +bayard +bayer +bayerkoh +bayless +bayley +bayly +bayne +baynes +bayno +bayola +bayrakta +bays +bazarjan +bazemore +bazerghi +bazerman +bazik +baziuk +bcs +bcspatch +bea +beach +beadley +beagley +beal +beale +beall +bealle +beals +beana +bear +beardmor +bearnard +bears +beasley +beata +beate +beato +beaton +beatrice +beatrisa +beatrix +beatriz +beattie +beattie- +beatty +beaty +beau +beaubien +beaucair +beauchai +beaucham +beauchem +beaudet +beaudett +beaudin +beaudoin +beaudry +beaufort +beaule +beaulieu +beaumier +beaumont +beaupre +beaurega +beausejo +beauvais +beavingt +beavis +beb +bebber +bebe +bebee +becan +becca +bechara +bechtel +beck +becka +becke +becker +beckett +beckham +becki +beckie +beckman +beckstea +beckwith +becky +beconovi +becquart +bedard +bede +bedford +bedi +bedient +bedlingt +bednar +bedoya +bedrosia +bee +beebe +beeby +beecker +beehler +beekman +beeman +beerkens +beers +bees +beeston +beethove +beeton +befanis +beffert +beggs +begley +begum +behdad +behlen +behler +behm +behnam +behrens +behroozi +behrouz +behzad +beil +beilin +beilul +beine +beique +beisel +beitinja +beitris +bejar +bekkedam +bekki +bektas +bel +bela +belair +belaire +beland +belanger +belboul +belcher +belcourt +belen +belford +belia +belich +belicia +belinda +belir +belisle +belissa +belita +belk +bell +bella +bellanca +belle +bellefeu +bellehum +bellevil +bellew +belley +bellina +bellingt +bellis +bello +bellosa +belmont +belohoub +belrango +belson +belton +beltran +belva +belvia +belyaev +belzile +bemiller +bemis +ben +ben-isha +benabdal +benasso +benavide +benay +benchimo +bencia +benda +bender +bendick +bendicty +bendite +bendix +beneda +benedek +benedett +benedick +benedict +benedikt +benefiel +benefits +beneteau +benetta +benfield +benge +bengt +bengtson +benham +beniamin +beninger +benita +benito +benjamen +benjamin +benjavan +benjes +benji +benjie +benjy +benn +bennatt +benne +bennefel +benner +bennesa +bennet +bennett +benni +bennie +benning +bennison +benny +benoit +benoite +benschop +benski +benson +bent +benthem +benthin +bentlee +bentley +bento +benton +benwell +benyamin +benyon +benzick +benzie +beom-sah +beomsahn +beorn +beowulf +bep +beppie +ber +beranger +berek +berenbac +berenice +berenz +beresfor +beresnik +beret +bereza +bergado +berger +bergeron +bergeson +berget +bergland +bergman +bergmann +bergquis +bergsma +bergstro +bergwerf +berhane +beriault +berk +berke +berkeley +berkie +berkley +berkly +berknet +berky +berman +bermel +bern +berna +bernaden +bernadet +bernadin +bernard +bernardi +bernardo +bernarr +bernd +berndt +berne +berneche +bernelle +berneta +bernete +bernetta +bernette +bernhard +berni +bernice +bernie +berniece +bernier +berning +bernita +berno +bernstei +berny +berri +berrie +berrin +berrisfo +berro +berry +berryhil +bert +berta +berte +berteau +bertha +berthe +berthele +berti +bertie +bertigno +bertina +bertine +bertini +bertolin +berton +bertram +bertrand +berty +berube +beryl +beryle +beshai +besharah +beshir +besime +besnier +bess +besse +bessel +bessell +bessette +bessey +bessie +besson +bessuill +bessy +bestavro +beswick +betcher +beth +bethanne +bethany +bethena +bethina +bethune +beton +betsey +betsill +betsy +betta +bettadap +bette +bette-an +betteann +betterle +betters +betti +bettie +bettina +bettine +bettink +betts +betty +betty-an +bettye +beulah +beun +beuren +bev +bevan +beveridg +beverie +beverlee +beverley +beverlie +beverly +bevin +bevingto +bevis +bevon +bevvy +bevyn +beware. +beygui +beymer +bezanson +bezdel +beznowsk +bhagvat +bhal +bhandari +bhanu +bharadwa +bharat +bhardwaj +bhasin +bhaskar +bhatia +bhatt +bhattach +bhatti +bhavani +bhoday +bhullar +bhupendr +bhupinde +bhusan +bi-jun +bi-shiou +biage +bialek +bialkeni +biamonte +bianca +bianchi +bianka +biard +bibbie +bibby +bibbye +bibekana +bibi +bible +bibr +bice +bickford +bidc +biddie +biddy +bidetti +bidget +bidyut +biederma +biegaj +biel +bielan +bielat +bielby +bielecki +bielejes +bienek +bienia +bierbrie +bierman +biermann +biersach +bieszcza +bigelow +biggers +biggerst +biggs +bigley +bigras +bihari +bihl +bijan +bijjani +bijman +bijons +bijun +bil +bilal +bilanski +bili +bill +billard +billi +billie +billing +billingh +billotea +billy +billye +bilodeau +bilovus +bilsboro +bilton +bimini +bin +bina +binda +binder +bindi +binette +bing +binggeli +bingham +bingley +bingwu +binh +bink +binkley +binky +binner +binni +binnie +binningt +binny +bins +biomecha +biomed +bipin +biplab +bir +biray +birch +bird +birdie +birendra +birgit +birgitta +birgitte +birk +birkett +birks +birkwood +birmingh +biron +birtch +bisad +bisch +bishiou +bishwa +bishya +bismark +biss +bissegge +bissette +bisson +bissonne +biswa +biswajit +bitar +bittenbe +bittman +bitton +bivens +bizga +bjorklun +bjorn +bjornson +blaauw +blackard +blackbur +blacker +blackley +blackloc +blackman +blacksha +blackshi +blackwel +blackwoo +bladon +blaikloc +blaine +blair +blaire +blais +blaise +blake +blake-kn +blakelee +blakeley +blakemor +blakesle +blakey +blakkolb +blalock +blanca +blanca-s +blancasi +blanch +blancha +blanchar +blanche +blanchet +blanco-a +blander +blane +blankens +blann +blaschuk +blasine +blasing +blasko +blatherw +blatt +blau +blauer +blaufus +blaylock +blayne +blazejew +blazek +blazer +bleile +blenk +blenkarn +blesi +blethen +bleuer +blevins +blezard +blidy +blimkie +blinn +blinni +blinnie +blinny +bliss +blisse +blissett +blithe +blodgett +bloedon +bloemker +blois +blomquis +blondell +blondie +blondy +bloodwor +blostein +blouin +blount +bluethne +blum +blumenfe +blumer +bluschke +bly +blyskal +blyszcza +blythe +bmethods +bnr +bnrecad +bnrinfo +bnrlsi +bnrsport +bnrtor +bo +bo-ping +boal +boaman +boarder +boase +boatwrig +bob +bobak +bobar +bobb +bobbe +bobbee +bobbette +bobbi +bobbie +bobbitt +bobby +bobbye +bobette +bobina +bobine +bobinett +boccali +bockaj +bocklage +bocservi +boddevel +boden +bodford +bodin +bodkin +bodnar +bodo +boeck +boecke +boehlke +boehms +boen +boer +boersma +boeyen +bogal +bogart +bogdan +bogert +bogey +boggan +boggia +boggild +boggs +bogumill +boguslaw +bohacek +bohanan +bohannon +bohdan +bohn +bohner +bohyun +boigie +boileau +boily +boinnard +bois +boisseau +boisset +boisvert +boivin +bojeck +bokanovi +bokij +bokish +boland +bolding +bolduc +boleda +bolen +boles +bolgos +bolio +bolli +bolly +bolon +bolouri +bolsinge +bolton +bolzon +bomba +bombardi +bommakan +bommer +bomstein +bon +bonahoom +bond +bondie +bondon +bonduran +bondy +bone +bonfanti +bongers +boniface +bonita +bonn +bonnar +bonneau +bonnee +bonnefoy +bonnell +bonner +bonnevil +bonney +bonni +bonnibel +bonnie +bonnin +bonny +bono +boocock +booker +booking +bookings +boon-sio +boone +boonie +boonphet +boonsion +boony +boorne +boorse +boos +boose +boot +boote +booth +boothe +boothroy +bophal +boping +bopp +boppana +bor-wen +bora +boraie +boray +borcic +bord +bordage +borden +bordie +bordin +bordy +borek +borel +borg +borgia +borha +boris +borivoje +borkowic +borman +borodajl +borojevi +borosch +borosh +boroski +boroughs +borowiec +borozny +borrelli +borsa +borsato +borson +bortenst +borthwic +bortolus +borum +boruslaw +borwen +borza +borzic +bosch +boschin +boscio +bosco +bose +bosiljev +bosiljka +bosko +bosnich +bosnyak +bossa +bossert +bossett +bossler +bostelma +bostock +boswell +boswick +bosworth +bosy +bot +bothwell +bott +botti +botting +bottis +botto +bottomle +bottoms +botyrius +bouchard +boucher +boucouri +boudin +boudreau +bouffard +bouick +boulais +boulay +bouleric +boulos +boult +bounds +bour +bourahla +bourbonn +bourcier +bourdeau +bourdign +bourdin +bouret +bourgaiz +bourgaul +bourget +bourgon +bourguig +bourk +bourke +bourland +bourlet +bourne +bouroncl +bourque +bourret +bousfiel +boutilie +boutin +boutniko +boutot +bovat +bovee +bovenize +bovey +bowab +bowcock +bowden +bowen +bowens +bower +bowers +bowes +bowick +bowie +bowler +bowles +bowling +bowser +bowyer +boy +boyachek +boyajian +boyce +boycey +boycie +boyd +boye +boyea +boyer +boyes +boylan +boyle +boynton +boz +bozeman +bozicevi +bqb +braaksma +brabant +brabec +bracewel +brackin +brackley +bracy +brad +bradan +bradbury +bradd +braddock +braddy +brade +bradee +braden +bradford +brading +bradlee +bradley +bradlow +bradly +bradnels +bradney +bradshaw +brady +bradyhou +bragado +braganza +bragg +braginet +braham +brahim +brahmana +brahms +brailey +brain +brait +brajesh +bram +brambley +bramlett +bran +brana +branchau +brand +brandais +brande +brandea +brandel +branden +brander +brandi +brandice +brandie +brandise +brandon +brandsen +brandsta +brandt +brandtr +brandvol +brandy +brandyn +branham +brann +brannan +brannen +brannick +brannon +brans +branscom +brant +brantley +brar +brashear +brasingt +brassard +brassell +brassem +brasset +brasunas +brathwai +bratten +brauer +brault +braum +braun +braunsti +braverma +brawley +brazeau +breanne +brear +brearley +breault +brechtje +bredeck +bredfeld +bree +breedlov +breena +bregitte +breglec +brehm +breisch +breiten +brekel +brel +bremner +bren +brena +brend +brenda +brendan +brenden +brender +brendin +brendis +brendon +brenn +brenna +brennan +brennand +brennen +brent +brentley +brenton +breon +brese +bresee +breslin +bresnaha +bresnan +bress +bret +breton +brett +breuer +brevard +brew +brewer +brewster +brewton +bria +brian +briana +brianna +brianne +briano +briant +briante +briard +brice +brichett +brickey +brickman +bride +briden +bridenst +bridge +bridgefo +bridges +bridget +bridgett +bridgman +bridie +brieda +briel +brien +brier +briere +brierley +brietta +brig +brigg +briggs +brigham +brightwe +brigid +brigida +brigit +brigitta +brigitte +brina +brind'am +brindley +briner +briney +bringhur +brinklow +brinkman +brinn +brinna +brintnel +brinton +briny +brion +brisby +briseboi +brissett +brisson +brit +brita +britman +britney +britni +britt +britta +brittain +brittan +brittane +brittani +brittany +britte +britteny +brittne +brittney +brittni +britto +britton +brivet +brivins +brkich +brnaba +brnaby +broadfoo +broadhea +broadwel +broberg +broca +brocato +brock +brockhou +brockie +brockleb +brockman +brockmey +brocksch +brocky +brod +broddie +broddy +broderic +broderse +brodfueh +brodgen +brodie +brodman +brodowsk +brody +brogden +brogdon +brogley +brok +brokaw +brombal +bromley +bron +bronec +bronk +bronnie +bronny +bronson +brook +brooke +brooker +brookes +brookhar +brookhou +brooks +brooksba +broome +brophy +broschuk +brose +brossard +brossela +brosso +brost +brostrom +broten +brothers +brothert +brough +broughto +brouille +broulik +broussar +broussea +brouthil +brouwer +brovont +brower +brown-gi +browne +brownfie +browning +brownlee +brownlie +brownrid +brox +broyles +brubaker +bruce +brucie +bruder +bruhl +bruin +bruis +bruketa +brule +brum +brummitt +brummund +brunato +bruncati +bruneau +brunel +brunelle +bruner +bruner-u +brunet +brungard +brunhild +brunke +brunner +brunner- +bruno +brunoni +brunstin +brunton +brushey +bruxvoor +bry +bryan +bryana +bryant +bryanty +bryce +brydges +brydon +bryenton +bryn +bryna +brynn +brynna +brynne +bryon +brys +bryttan +bse +bubak +bubel +buccella +bucci +buchan +buchanan +buchko +buck +buckalew +buckhoff +buckie +buckingh +buckley +bucklin +buckman +buckner +bucky +buczek +bud +buda +budd +buddie +buddy +buder +budhram +budi +budihard +budimiro +bue +buechner +buehler +buettgen +buffam +buffett +buffy +buford +bugajska +bugajski +buggie +buhler +buhr +buhrkuhl +bui +building +buiron +bujold +buker +bukowski +bukta +buky +bulan +bulanda +bulbrook +bulengo +bulent +buley +bulger +bulifant +bulitka +bulka +bulkovsh +bullard +bullas +bullen +bulletin +bullett +bullinge +bullion +bulman +bulmanis +bulmer +bulz +bumgarne +bumstead +bunce +bundschu +bunker +bunn +bunner +bunni +bunnie +bunny +bunting +buntrock +bunzey +buratyns +burbage +burbidge +burcew +burch +burchat +burchby +burdett +burdette +burdick +burega +burek +burg +burge +burger +burgess +burgette +burgi +burgin +burgwell +burk +burkard +burke +burkepil +burkert +burkett +burkey +burkhard +burl +burleigh +burleson +burlie +burnaby +burnage +burnard +burness +burnet +burnett +burnette +burney +burnie +burns +burnside +burr +burrell +burrowes +burrows +burrus +burruss +burt +burtie +burton +burty +burwell +busby +buscagli +buscarin +busch +busche +buschelm +bushell +bushnell +bushnik +business +buskard +buske +buskens +busko +bussewit +bussey +buster +bustillo +busuttil +butch +butcher +butner +butta +butterfi +butters +buttrey +butts +butvich +buxton +buzz +buzzell +buzzy +bvworks +by don o +bycenko +byczko +bydeley +byer +byers +byeungwo +byk +bykowy +bylina +byoung +byoungin +byram +byran +byrann +byrd +byrgesen +byrl +byrle +byrne +byrnes +byrom +byron +byung +byungyon +cabaniss +cabi +cabot +cabral +cabras +cabrera +caceres +cacha +cachero +cacilia +cacilie +cad +cadd +caddric +cadeau +cadieux +cadshare +cadtools +cady +cadzow +cae +caesar +caffrey +caffry +cagatay +caglar +caglayan +cahill +cahra +cai +caie +cain +caine +caines +cairisti +cairns +caison +caitlin +caitrin +cakarevi +cal +calahorr +calc +calcote +calder +caldwell +cale +caleb +caleta +calhoun +caliboso +calica +calida +calis +calistro +calkins +calla +callagha +callahan +callan +callanan +callean +calleja +callende +callery +calley +calli +callida +callie +callos +calloway +cally +calmejan +calmenso +calow +caltride +calumet +calv +calva +calvary +calvin +calypso +calzaros +cam +camacho +camala +camblin +cambre +camel +camel-to +camella +camellia +cameron +camet +camey +cami +camie +camila +camile +camilla +camille +camilluc +camino +camirand +cammi +cammie +cammy +campagna +campanel +campara +campbell +campeau +camplone +campo +campos +canada +canadian +canavan +cancela +candace +candee +candelar +candi +candice +candida +candide +candie +candis +candra +candy +canete +canfield +cang +cann +cannatar +cano +cantlie +cantrell +cantwell +canuel +canute +capelle +capes +capindal +caple +caplinge +capobian +capostag +capozzi +capps +capretta +caprice +captives +caputo +car +cara +caralie +carandan +carbajal +carbonar +carbone +carboni +carbonne +carce +cardella +carden +cardozo +cards +care +career +careers +carella +caren +carena +caresa +caresani +caressa +caresse +carevic +carew +carey +cargill +cargnell +cari +caria +caridad +carie +carignan +caril +carilyn +carin +carina +carine +cariotta +carisa +carissa +carita +caritta +cark +carkner +carl +carla +carlberg +carldata +carle +carlean +carlebac +carlee +carleen +carlen +carlene +carleton +carlett +carley +carli +carlie +carlin +carlina +carline +carling +carlis +carlisle +carlita +carlo +carlock +carlos +carlota +carlotta +carlsen +carlson +carlton +carly +carlye +carlyle +carlyn +carlynn +carlynne +carm +carma +carmel +carmela +carmelia +carmelin +carmelit +carmella +carmelle +carmelo +carmen +carmenci +carmicha +carmina +carmine +carmita +carmody +carmon +carmona +carnegie +carney +carnogur +carny +caro +carol +carol-je +carola +carolan +carolann +carole +carolee +carolien +carolin +carolina +caroline +caroljea +carolle +carolus +carolyn +carolyne +carolynn +caron +carpenti +carpool +carr +carran +carranza +carree +carri +carrie +carriere +carrillo +carringt +carrissa +carrmtce +carrol +carroll +carron +carruthe +carry +carrye +carson +carsten +carstens +carswell +cart +carter +cartohl +carty +carufel +caruk +caruso +caruth +carvalho +carver +cary +caryl +caryn +cas +casadont +casalou +casandra +casanova +casar +casas +cascarin +case +casey +cash +cashin +casi +casie +casinovi +caskey +casler +casnji +casotto +caspar +casper +casperso +cass +cassady +cassandr +cassar +cassat +cassaund +cassese +cassey +cassi +cassian +cassidy +cassie +cassius +casson +cassondr +cassy +castaban +castell +castello +casten +castillo +casto +castongu +castro +castro-h +castrono +caswell +cat +catanach +catarina +cate +caterina +catering +cath +catha +cathal +catharin +cathe +cathee +catherin +catherwo +cathi +cathie +cathleen +cathlene +cathrin +cathrine +cathryn +cathy +cathylee +cati +catie +catina +catja +catlaina +catlee +catlett +catlin +cato +caton +catrina +catriona +catthoor +caty +cau +cauchy +caudill +caudle +cauthen +cauthers +cavan +cavanagh +cavanaug +cavasin +cavasso +caves +cavill +cavin +caviness +cavnar +cawley +caye +cayer-fl +cayla +cayless +cayouett +caz +caza +cazzie +cbabbage +cchaddie +cecco +cece +cecelia +cech +cecil +cecile +ceciley +cecilia +cecilio +cecilius +cecilla +cecily +cecon +ced +cedric +cefee +cegelski +ceil +cele +celene +celesta +celeste +celestia +celestin +celestyn +celia +celie +celina +celinda +celine +celinka +celisse +celka +celle +cello +cellucci +celso +celyne +cemensky +cen +cencier +centeno +center +centers +centis +centre +cepero +cepheus +ceponis +ceranic +cerberus +ceri +ceriel +cerny +cervante +cesar +cesaratt +cesare +cesario +cesaro +cescon +cesya +cetraro +cezary +chaaban +chaar +chabane +chabert +chabrat +chacko +chacon +chad +chadd +chaddha +chaddie +chaddock +chaddy +chadha +chadrick +chadwick +chafin +chafy +chagnon +chahal +chahram +chai +chai-seo +chaikows +chaim +chaiman +chaimson +chaintre +chaisupa +chak-hon +chakraba +chakrabo +chakrava +chalifou +chalker +challice +chalmers +chalton +cham +chamard +chamayou +chambers +chamblis +champath +champion +champsi +chamsi +chan +chan-jiu +chan-nan +chance +chancey +chanchal +chanchla +chanco +chand +chanda +chandal +chandan +chander +chandler +chandra +chandrak +chandran +chandras +chandru +chane +chang +chang-hs +changes +changho +changhsi +chanh +chanitr +chanjiun +channa +channan +channell +channen +chanonat +chanpong +chanshin +chansik +chantal +chantall +chantel +chantell +chao +chao-pin +chaoping +chapa +chapdela +chapen +chapin +chapleau +chaplin +chapman +chapmond +chappell +chappuis +chaput +char +charangi +charasse +charbonn +charchan +chardon +charee +charene +charest +charette +chari +charil +charin +chariot +charis +charissa +charisse +charita +charity +charko +charla +charlean +charlebo +charleen +charlena +charlene +charles +charlesb +charleto +charley +charlie +charline +charlino +charlins +charlot +charlott +charlsey +charlton +charly +charmain +charman +charmane +charmian +charmine +charmion +charness +charney +charo +charon +charron +charter +chartier +chartran +charyl +chas +chasalow +chase +chasse +chastity +chatard +chatchai +chatel +chatfiel +chatha +chatri +chatterl +chattert +chattoe +chattos +chau +chaudhar +chaudhry +chaudry +chaug-mi +chaugmin +chauhan +chaunce +chauncey +chaurasi +chaurett +chautems +chauvin +chavers +chaves +chavez +chavis +chawki +chawla +chaya +chaz +che +chea +cheal +cheatham +cheba +checinsk +checklan +chee +chee-yin +chee-yon +cheesema +cheesman +cheetham +cheevers +chel +chellapp +chelsae +chelsea +chelsey +chelsie +chelsy +chem +chen +chen-che +chen-chu +chen-jun +chen-msi +chenard +chenault +chenchun +chene +cheney +cheng +cheng-do +cheng-fo +cheng-ho +cheng-hu +cheng-ts +chengdon +chengfoo +chenghon +chenghun +chengtse +chengwei +chenier +chenmsie +chennett +chenowet +chenye +cheol +cheow-to +cheowton +chepregi +cher +chere +cherenso +cherey +cheri +cherian +cheriann +cherice +cherida +cherie +cherilyn +cherin +cherise +cherish +cherkas +cherlyn +chern +chernets +cherng +cherri +cherrier +cherrita +cherry +chertok +chervena +chery +cherye +cheryl +ches +cheshire +chesley +cheslie +chesser +chesteen +chester +chesterf +cheston +chet +chetan +cheuk +cheung +chev +cheval +chevalie +chevarie +chevy +cheyenne +chhabria +chi +chi-haw +chi-ho +chi-hua +chi-hung +chi-kai +chi-keun +chi-kwan +chi-man +chi-vien +chi-wen +chi-yin +chi-yuan +chia +chia-hoa +chia-hua +chiabaut +chiahoan +chiahuan +chiaki +chiamvim +chian +chian-fo +chianfon +chiang +chiang-h +chianghu +chiaoyun +chiarell +chiarra +chiavaro +chic +chick +chickie +chickori +chicky +chico +chie +chief +chieh +chiem +chien +chien-ch +chien-hs +chien-hu +chienche +chienchi +chienhsi +chienhue +chieu +chih +chih-chi +chih-hsi +chih-hua +chih-tsa +chihaw +chihchia +chihchie +chihhsia +chihhua +chihtsai +chihua +chihung +chiiwen +chik +chikai +chilausk +childerh +childers +childree +childres +childs +chilibec +chilton +chima +chin +chin-ho +chin-lin +chin-shu +chin-ten +chin-wen +chinfui +ching +ching-ch +ching-en +ching-fu +ching-lo +ching-ts +ching-yu +chingchy +chingen +chingfu +chingtsu +chingyun +chinh +chinhin +chinho +chiniwal +chinlin +chinn +chinnery +chinrung +chinshu +chinteng +chinwen +chiou +chip +chiquia +chiquita +chiracha +chisholm +chisolm +chitkara +chitnis +chitra +chityal +chiu +chiverto +chiwen +chiykows +chiyo +chiyuan +chlo +chloe +chloette +chloris +chmara +cho +cho-kuen +cho-lun +chochon +chocs +chod +choe +chohan +choi +chojan +chok +cholet +cholette +cholewin +chomik +chona +chonchan +chong +chong-ch +chong-ke +chong-la +chongcha +chongkeu +choo +choo-kan +choon +choon-li +choong +chopin +chopowic +chopra +choptovy +choquett +chorley +chorng +chotkows +chou +choudhur +chouhan +chouinar +chowhan +choy +choynows +chriisto +chris +chrisman +chrisoph +chrisse +chrissie +chrissun +chrissy +christ +christa +christab +christal +christan +christea +christel +christen +christer +christi +christia +christie +christin +christl +christof +christop +christos +christy +christye +christyn +chrisy +chronowi +chrotoem +chroust +chruscie +chrysa +chrysant +chrysler +chrystal +chryste +chrystel +chu +chu-chay +chu-chue +chua +chuah +chuan +chuan-hs +chuang +chuanhsi +chubb +chubby +chucho +chuchuen +chuck +chueh +chuen +chugha +chui +chuj +chuk +chukwuem +chul +chuming +chummun +chun +chun-li +chun-shi +chun-yen +chung +chung-ch +chung-kw +chung-li +chung-wo +chung-yo +chungen +chungjen +chungkwo +chunglin +chungpha +chungsik +chunkin +chunlan +chunli +chunlin +chunling +chunmei +chunmeng +chunn +chunshin +chunyen +chuong +chuq +churas +churchil +chwen +chychrun +chye +chye-lia +chytil +cia +ciampini +cianci +ciancibe +ciaralli +ciaran +ciaschi +ciccarel +cicchino +cicci +cicek +cicely +cicero +cicily +ciel +ciesiels +cieslak +cifelli +cifersky +cigay +cilka +cimarron +cimino +cimolai +cinar +cinda +cindas +cindee +cindelyn +cinderel +cindi +cindie +cindra +cindy +cinicolo +cinnamon +cinq-mar +ciocca +ciochon +cioffi +ciolfi +cipolla +circe +ciriaco +cirillo +cirilo +ciro +cirri +cirstofo +cirulli +cis @ w +cisco +ciskowsk +cisnews +cissiee +cissy +citarell +cities +citrin +cividino +cizmar +clacher +claggett +claiborn +clair +claire +clairmon +claise +clampitt +clancy +clapham +clapp +clara +clarabel +clarance +clare +clarence +claresta +clareta +claretta +clarette +clarey +clari +claribel +clarice +clarie +clarinda +clarine +clarise +clarissa +clarisse +clarita +clark +clark-st +clarka +clarke +clarkson +clary +clason +class +classes +claude +claudell +claudett +claudia +claudian +claudie +claudina +claudine +claudio +claudius +claus +claveau +claxton +clay +clayborn +claybour +claybroo +clayson +clayton +clea +cleary +cleavlan +cleere +clegg +clem +clemence +clemens +clement +clemente +clementi +clements +clemie +clemmie +clemmons +clemmy +clendeni +clenney +clennito +clentice +cleo +cleon +cleopatr +clerc +clerissa +clerkcla +clerke +cleroux +clesson +clestell +cletis +cleto +cletus +cleve +clevelan +clevey +clevie +clevon +cliff +clifford +clifton +clim +clincket +cline +clinger +clinkard +clint +clinteas +clinton +clio +clippert +clipsham +clites +clive +clo +clocklab +cloe +cloherty +clooney +cloris +closson +clost +clotilda +clough +clouthie +cloutier +clovis +clow +cloyd +cluett +clusiau +cly +clyde +clysdale +clyve +clywd +cmet +co +co-op +co-ordin +coady +coallier +coathup +coats +cob +cobaugh +cobb +cobban +cobbie +cobbold +cobby +coble +cobley +cobo +cobran +cocco +cochran +cochrane +cockburn +cockcrof +cocke +cockins +cocos +cocos-ar +codack +codata +coddingt +code +codee +coder +codi +codie +codoc +codringt +cody +coe +coertnik +coffey +cogan +cogdell +coggins +coghlan +cogwell +cohea +cohen +cohn +cohn-sfe +cohoe +cohrs +coila +cointon +coker +cokol +colagros +colan +colangel +colanton +colas +colatta +colbert +colbourn +colburn +colby +colclasu +coldwell +cole +coleen +colella +coleman +colene +coles +colet +coletta +colette +coley +colford +colgan +colin +colina +colinda +collamer +collamor +collazo +collecut +colledge +colleen +collen +collete +collette +collevec +colley +colli +collie +collier +collin +colline +collins +collis +colly +collyer +colm +colman +coloads +colonton +colpitts +colquett +colquhou +colquitt +colston +colter +colterma +colton +colucci +colver +colvin +colwell +comay +combaz +combee +combella +combos +combs +comeau +comley +comm +commazzi +comments +committe +commons +communic +comp +compton +computin +comstock +comtois +con +conan +conant +conboy +concetta +concetti +conchita +concklin +concordi +conde +condell +condurel +conerly +coneybea +cong +congdon +congress +conistis +conklin +conley +conlin +conlon +conn +connell +connelly +conner +conners +conney +conni +connie +connolly +connor +connors +connors- +conny +conoly +conrad +conrade +conrado +conrath +conroy +consalve +conservi +consolat +constabl +constanc +constant +construc +consuela +consuelo +consulta +containi +contardo +conte +contine +contomic +conway +coochey +coody +coogan +cooke +cookie +cooksey +cooley +coolidge +coombs +cooney +coop +cooper +cooperma +coord +coordina +coors +copeland +copello +copeman +copes +coplesto +copley +copp +coppedge +coppins +coqueugn +cora +corabel +corabell +corace +coral +coralie +coraline +coralyn +corbeil +corbet +corbett +corbie +corbin +corbitt +corby +corcoran +cord +cordelia +cordelie +cordell +cordes +cordey +cordi +cordie +cordula +cordy +core +coreen +corella +corena +corenda +corene +coretta +corette +corey +cori +coriaty +corie +corilla +corina +corine +corinna +corinne +coriss +corissa +corker +corkey +corkigan +corkstow +corkum +corless +corlett +corley +corliss +corly +cormac +cormier +cornall +cornaro +cornel +cornela +cornelia +cornelis +corneliu +cornell +cornelle +corner +corney +cornie +corny +corpenin +corpuz +corr +correa +correia +correna +correy +corri +corriann +corrie +corrigan +corrina +corrine +corrinne +corritor +corrivea +corry +corsale +corse +corson +cort +cortie +cortland +cortney +corty +corvo +cory +cos +cosburn +cosentin +cosetta +cosette +cosgrove +cosimo +coslas +cosme +cosmo +cosner +cosola +cossota +costa +costache +costadim +costandi +costanti +costanza +costanzi +costas +costas-d +coste +costello +costen +cote +cothran +cotnam +cotner +cotten +cottengi +cotter +cottingh +cottrell +cotugno +cotuna +coucopou +couey +coughran +coules +coulman +coulombe +coulson +coulter +coulterm +count +coupal +coupland +courches +couron +coursdev +coursey +coursol +courson +court +courtadm +courtena +courtlan +courtnay +courtney +courvill +couse +couser +cousinea +cousins +coutelli +coutinho +couto +coutu +couture +covach +coverdal +covey +coviensk +coville +covingto +cowan +cowart +cowell +cowen +cowick +cowley +cowling +cowlisha +cownie +cowper +coxall +coxe +coyle +coyne +cozart +cozmo +cozyn +cozzi +cpebach +cpm +cprs +crabb +crabe +crabtree +cracknel +craddock +crafton +craggie +craggs +craggy +craghead +craib +craig +craig-du +crain +cramer +cramm +crampton +crandall +cranford +cranston +crapco +crase +craver +crawford +crawhall +crawley +crawshaw +cray +craycraf +cre +creamer +crean +creane +creasey +creasman +creative +credico +credille +creech +creecy +cregan +creigh +creight +creighto +cremer +crepeau +crerar +creswell +crews +cribbs +crichton +crick +crickard +cricker +cricket +crigger +crin +crippen +cripps +cris +crisler +crissie +crissy +crista +cristabe +cristal +cristen +cristesc +cristi +cristian +cristie +cristin +cristina +cristine +cristion +cristoba +cristofa +cristy +criswell +critchle +crittend +crl.word +crocker +crockett +crogie +croiseti +croix +crolla +cromer +crommie +crompton +cromwell +cronan +cronin +cronk +cronkrig +cronkwri +crooks +croom +cropper +crosby +cross +cross-as +crossass +crossley +crosson +crosswel +croteau +crothers +crotty +crowder +crowe +crowell +crowle +crowley +croxall +croxford +crozier +crucefix +cruey +cruicksh +crumpton +crusoe +crutchfi +cruz +cruzado +cryoelec +crysta +crystal +crystalb +crystie +csaszar +csenar +csilla +csite +csma +csop +csr +csreport +csua +ctas +cthrine +cuany +cuauhtem +cubical +cubicle +cucchiar +cucci +cuccia +cucciole +cucuzzel +cuddihey +cuddihy +cuddy +cuellar +cuervo +cuffle +cuffling +cuggy +culberso +culberts +culbreth +culham +culkin +cull +cullan +cullen +culley +cullie +cullin +culliphe +cullum +cully +culmer +culp +culver +culverho +cummine +cumming +cummings +cummins +cumpston +cunanan +cung +cunha-go +cunningh +cuong +cuper +cupid +cupido +curcio +curley +curmon +curnow +curr +curran +currer +currey +currie +currier +currin +curry +curt +curtice +curtin +curtis +curtt +cusato +cushing +cushman +cusick +cusson +custer +custsupp +cusumano +cuthbert +cuthill +cutrufel +cutter +cuu +cwirzen +cy +cyb +cybil +cybill +cybotech +cycelia +cymbre +cynde +cyndi +cyndia +cyndie +cyndy +cynethia +cynthea +cynthia +cynthie +cynthy +cynthya +cyr +cyril +cyrill +cyrille +cyrillus +cyrine +cyros +cyrus +cytrynba +czappa +czarneck +czeban +czes +czeslaw +czychun +d'ambros +d'amico +d'amour +d'andrea +d'angelo +d'anjou +d'anne +d'antoni +d'aoust +d'arcy +d'cruz +d'ingian +d'ippoli +d'lima +d'onofri +d'orazio +d'silva +d'soto +d'souza +da +da gama +da silva +da-shih +daaboul +dacal +dace +dacey +dach +dachelet +dacia +dacie +dack +dacre +dacy +dada +dadalt +dadang +dade +dadgar +dadkhah +dae +daebum +daedalus +dael +daena +daesik +daffi +daffie +daffy +dafoe +dag +dagama +dagenais +dagert +dages +dagg +dagley +dagmar +dagnall +dagnaw +dagny +dagoulis +dahai +dahan +dahi +dahl +dahlia +dahlstro +dai +daigle +daigneau +daijavad +daile +dailey +daimee +dairin +daisey +daisi +daisie +daisy +dajerlin +dal +dale +dalenna +dales +daley +dalia +dalila +dalip +dalis +dall +dall'ost +dallago +dallaire +dallal +dallas +dalli +dallis +dallon +dalmard +daloris +dalrympl +dalsiel +dalston +dalt +dalton +daly +damara +damaris +dambenie +dame +damena +damerji +damian +damiano +damien +damil +damita +damon +damone +dan +dana +danagher +danai +danbrook +danchi +dancy +dando +danduran +dane +danell +danella +daneshza +danette +danforth +dangubic +danh +dani +dania +danial +danica +danice +danie +daniel +daniela +danielak +daniele +daniella +danielle +daniells +daniels +danika +danila +danilo +danilowi +daniluk +danit +danita +danjean +danker +danko +danling +dann +danna +dannel +danni +dannie +danny +dannye +dans +danserea +dante +dantu +dantzler +dany +danya +danyelle +danyette +danzeise +danzig +dao +daochuan +daoud +daoust +daphene +daphine +daphna +daphne +daquano +dar +dar-der +dara +darb +darbee +darbie +darby +darcange +darcee +darcel +darcey +darci +darcie +darcy +darda +darden +darder +dare +dareen +darell +darelle +daren +dares +dari +daria +darian +darice +darill +darin +darina +dario +darius +darko +darla +darleen +darlene +darline +darlingt +darlleen +darn +darnall +darnel +darnell +darold +daron +darou +darpa +darr +darrel +darrell +darrelle +darren +darrick +darrimon +darrin +darroch +darrol +darrow +darry +darryl +darsey +darshan +darshana +darshi +darsie +daruius +darveau +darwen +darwin +darwyn +darya +daryl +daryle +daryn +daryoosh +daryoush +das +dasch +dasd +dasha +dasharat +dashih +dasi +dasie +dasilva +dasinger +dasrath +dassani +dassie +dasya +dat +data +datacent +datas +datasupp +datema +dates +datha +datta +dattalo +dau +daudin +daugavie +daughert +daughtre +daunais +daune +dauphina +dautenha +dauteriv +dav +davalo +dave +daveen +daven +daveta +davey +david +david-ye +davida +davidde +davide +davidh +davidovi +davids +davidson +davie +davies +davin +davina +davinci +davinder +davine +davis +davison +davita +davon +davor +davy +dawe +dawit +dawkins +dawn +dawna +dawne +dawson +daya +dayal +dayberry +dayle +daymond +dayna +dayton +db +dba +dbase +dbs +ddavid +ddene +ddocdb +de +de anda +de baets +de beaum +de belen +de boer +de buda +de cecco +de chabe +de cours +de crist +de eliza +de grace +de hoog +de la +de leon +de los +de marco +de marti +de muinc +de salis +de souza +de toni +de varen +de vito +de vries +de wiele +de wilto +de witt +de witte +de-anna +de-boer +de-ying +de_konin +deacetis +deadwile +deagle +deak +deakin +dealmeid +dealto +deames +dean +deana +deanda +deane +deanm +deann +deanna +deanne +deans +deanza +dear +dearaujo +deardurf +deason +deathera +deatrick +deb +debadeep +debasish +debassig +debbi +debbie +debby +debee +debera +debernar +debi +debkumar +deblois +debnam +deboer +deboor +debor +debora +deborah +debord +debortol +debra +debrah +debrun +debrusk +debs +decacque +decaire +decapua +decarie +decasper +decca +decelles +deciccio +deck +decker +declan +decleir +decource +decourcy +decoursi +deczky +dedas +dede +dedie +dedra +deduk +dee +dee dee +deeann +deeanne +deedee +deek +deena +deep +deepak +deerdre +deere +deery +deetta +deevey +deeyn +defacend +defalco +defazio +defilipp +deford +deforeit +defrance +defranch +degan +degen +degenova +degraauw +degrandi +deguines +deguire +dehaan +dehghan +dehlia +dehner +dehoff +dehr +deiadrel +deibert +deicher +deidre +deikman +deina +deininge +deirdre +deitera +deitiker +dejan +dejongh +dekai +dekeyser +del +dela +delaat +delage +delahay +delainey +delancey +delaney +delangis +delano +delargy +delat +delbert +delbret +delbridg +delbrouc +delcina +delcine +deleon +delf +delfin +delfreda +delgass +delgross +delia +deligdis +delila +delilah +delinda +delisle +deliva +dell +della +delle +delli +dellinge +delly +delmar +delmer +delmor +delmore +delnaz +delo +delolmod +delong +delora +delorenz +delores +deloria +deloris +delorme +delphine +delphini +delroy +deluca +deluce +deluco +delvecch +delzer +demarco +demarest +demchuk +dement +demeo +demers +demet +demeter +demetra +demetre +demetri +demetria +demetric +demetrio +demetris +demetriu +demeulem +demi +demidenk +demir +demjen +demmel +demone +demontlu +demorest +demorge +demott +demps +dempsey +dempster +demren +demuth +den +dena +dendi +dene +denebeim +deneen +denemark +denery +denest +denette +deng +deng-jyi +dengjyi +deni +denice +deniece +denike +denis +denise +denison +deniz +denley +denman +denmark +denna +dennen +dennet +denney +denni +dennie +denning +dennis +dennison +denno +denny +deno +denomme +denoon +denter +denton +denver +deny +denys +denyse +denzil +deog +deok +deol +deonne +depalma +depeltea +depew +dephoure +deployme +depooter +dept +dept. +der +der-chan +der-shen +deraadt +deraaf +derby +derbyshi +derecki +derek +derenzo +derganc +deri +derick +derika +derin +derk +derluen +dermardi +dermot +derome +derosa +derose +derosenr +derrek +derrett +derrick +derrik +derril +derron +derry +dersheng +derward +derwin +dery +deryck +des +desai +desalis +desantis +desautel +desch +deschamp +deschiff +descotea +descotes +desdemon +desgrose +desharna +desi +design +desilets +desimone +desirae +desire +desiree +desiri +desjardi +desjarla +deska +deslande +deslauri +desmarai +desmond +desmund +desoer +desorbay +desourdy +despain +despault +despinic +desplanq +despres +desroche +desrosie +dessain +desser +destech +destefan +destry +detjens +detlef +detleff +detlev +detloff +detra +deugau +deugo +deutschm +dev +deva +devadas +devan +devarenn +devault +deveau +devel +developm +deven +devenny +devenyi +devenyns +devera +devere +devette +devgon +devi +devices +devin +devina +devincen +devine +devinne +devland +devlen +devlin +devon +devondra +devonna +devonne +devora +devore +devouges +devreeze +devy +dew +dewain +dewart +dewayne +dewey +dewi +dewie +dewit +dewitt +dewitte +dex +dexiang +dexter +deying +deyirmen +deyoung +dezbah +dezoete +dg +dhaliwal +dhansukh +dhanvind +dhar +dharam +dharmara +dharmawa +dhaussy +dhawal +dheeraj +dhillon +dhinakar +dhir +dhiraj +dhiren +dhuga +dhupar +di +di cosol +di giamb +di maso +di millo +di ninno +dia-edin +diaconu +diahann +dialout +diamond +dian +diana +diandra +diane +diane-ma +dianemar +diann +dianna +dianne +diannne +diarmid +dias +diaz +dibenede +dibler +dicaprio +dick +dickard +dickens +dickerma +dickerso +dickeson +dickford +dickie +dickinso +dicks +dicksie +dickson +dicky +didani +didar +didi +didier +didio-du +dido +diduch +didylows +diec +diederic +diederik +diedrich +diee +diego +diekman +diemel +dien +diena +diener +diep +diepling +dierdre +diersch +diesing +dieter +dietra +dietrich +dieu +dieuwert +difalco +diffee +diffie +difilipp +difrance +digby +digenova +digiacom +digilio +dignam +dijaili +dijian +diju +dikaitis +dikens +dilallo +dilan +dilen +dilip +dilkie +dill +dillabou +dillard +dilley +dillie +dillingh +dillon +dillow +dilly +diloreto +dilpreet +dima +dimarco +dimarzo +dimas +dimetry +dimillo +dimitra +dimitri +dimitrio +dimitry +dimoueri +dina +dinaband +dinah +dinalic +dincamps +dineke +dinesh +dinges +dingle +dingley +dingman +dinh +dinhtran +dinkel +dinker +dinnervi +dinneyla +dinnie +dinnin +dinny +dino +dinsmore +dinur +diogo +dion +dione +dionis +dionisio +dionne +dionysia +dionysiu +dionysus +dipace +dipak +dipasqua +diperna +dipierro +dipietro +dipper +dirac +diradmin +dirbm +dirck +diretto +dirienzo +dirilten +dirk +dis +disalvo +discenza +discours +discover +disessa +disher +dishong +disisto +disney +dispatch +dissinge +distribu +dita +ditecco +ditko +dittburn +divyesh +dix +dixie +dixon +djavaher +djenana +djordje +djuan +dmaac +dmitri +dms +dmsdb +dmsrtime +dmuchals +dnadoc +dniren +dnsproj +do +doak +doan +dobbing +dobbins +dobbs +dobby +doble +dobransk +dobrosla +dobry +doc +docherty +dockendo +doctorjo +document +doczy +doda +dodd +dodds +dode +dodgson +dodi +dodie +dodier +dodman +dodson +dody +doe +doemer +doerfel +doerksen +doernber +doerr +doggett +dohan +doherty +doi +doig +doing +dokken +dokuzogu +dolan +dolezal +dolf +dolginof +dolgov +doliska +doll +dolley +dolli +dollie +dolly +dolores +dolorita +dolph +dolson +dom +domains +domanico +domas +dombrosk +domenic +domenick +domenico +domeniga +dominado +domine +dominga +domingo +domingue +domini +dominic +dominica +dominick +dominik +dominiqu +dommety +don +dona +donaghue +donahee +donahue +donak +donal +donald +donaldso +donall +donalt +donator +donaugh +donavon +doncaste +doncell +donegan +donelan +donella +donelle +donetta +dong +dong-ik +dong-moo +dong-pyo +dongik +dongmoon +dongpyo +donia +donica +donielle +donis +donita +donkers +donleyco +donlon +donn +donna +donnajea +donnamar +donne +donnell +donnelly +donner +donnette +donni +donnice +donnie +donny +donoghue +donohoe +donohue +donovan +dood +doodeman +dooley +doolin +doolittl +door +doortje +dora +doraine +dorais +doraiswa +doralia +doralie +doralin +doralyn +doralynn +doran +doray +dordari +dorden +dore +doreen +dorelia +dorella +dorelle +dorena +dorene +doretta +dorette +dorey +dori +doria +dorian +dorianne +dorice +dorie +dorin +dorine +dorion +dorion-m +doriot +doris +doris-ha +dorisa +dorise +dorit +dorita +dormer +dorn +dornback +doro +dorolice +dorolisa +dorotea +doroteya +dorothea +dorothee +dorothy +dorotich +dorr +dorra +dorree +dorreen +dorrell +dorri +dorrie +dorris +dorronso +dorry +dorsey +dorthea +dorthy +dorval +dory +dosanjh +dosenbac +doshi +dosi +doskas +dosref +doss +dost +dot +dotan +doti +dotsey +dotson +dotti +dottie +dottin +dotty +doublesi +doucet +doucette +doud +douet +doug +dougall +doughert +doughty +dougie +douglas +douglass +dougy +dourley +douville +dov +dovel +dover +dovydait +dow +dowd +dowding +dowdy +dowell +dower +dowjones +dowker +dowling +downer +downes +downey +downing +downs +dowse +dowser +doy +doyle +doyon +dpierre +dpn +dpnbuild +dpnis +dpnlab +dpnq&a +dpp +dpu +dr.jones +dr.seuss +drabek +drachman +draco +dracula +draffin +dragan +dragana +dragert +dragnea +drago +draier +drakage +drake +drako +drane +dransfie +draper +drappel +draves +dray +drayton +dre +dreddy +dredi +dreisbac +drenan +drennan +drescher +dresel +dresser +dressler +drew +drewes +drexel +dreyfus +dreyfuss +driedger +drieka +drinnan +driscoll +drissel +driver +drjones +drobnik +drolet +dromgool +drona +drop-box +dropin +droste +drouin +drseuss +dru +drubld +druci +drucie +drucill +drucy +drud +drudy +drugi +drugs +drumhell +drumm +drummer +drummond +drusi +drusie +drusilla +drusy +druzeta +drwiega +dryer +dryfoos +drynan +du berge +du-tuan +duan +duane +dube +dubeau +dubee +dubey +dubman +dubois +dubose +dubreck +dubreuil +dubroff +dubroy +dubuc +duc +duchaine +ducharme +duchesne +ducic +dud +duda +dudas +dude +dudgeon +dudley +dueck +duenas +duensing +dueppen +duer +duff +duffie +duffin +duffney +dufford +duffy +dufloth +dufour +dufresne +dugal +dugald +dugar +dugas +duggan +duguay +duisman +duke +dukes +dukey +dukie +duky +dula +dulaney +dulce +dulcea +dulci +dulcia +dulciana +dulcie +dulcine +dulcinea +dulcy +dulin +duljit +dulmage +dulsea +dulude +dumais +dumas +dummer +dumont +dumouche +dumps +dun +dunajski +dunbar +dunc +duncan +duncan-s +dundin +dunfield +dung +dungan +dunham +dunik +dunkelma +dunker +dunlap +dunlay +dunlop +dunmore +dunn +dunne +dunnett +dunning +dunningh +dunnion +dunphy +dunsmore +dunson +dunstan +duong +dupaul +duplacey +duplan +dupont +dupras +dupre +dupree +dupuis +dupuis-m +dupuy +duquette +dur +durali +duran +durand +durant +durantay +durante +durham +durie +durling +durnford +durose +durousse +durovic +durrell +dursse +durward +duryonna +dusan +dusko +dusomos +duster +dusty +dutch +dutcher +duthie +dutil +dutt +dutta +duvarci +duxbury +duy +duyck +dvm +dvs +dwain +dwaine +dwayne +dwight +dwyer +dyan +dyana +dyane +dyann +dyanna +dyanne +dyba +dybenko +dyck +dyckman +dyess +dyke +dylan +dyment +dyna +dynah +dynie +dyrdahl +dysart +dyson +dziamba +dziawa +dziemian +dzioba +dzulkarn +dzung +eachelle +eada +eades +eadie +eadith +eadmund +eagle +eagles +eakes +eakins +eal +ealasaid +eales +eamon +eamonn +eansor +earl +earle +earlene +earles +earley +earlie +earline +early +earnest +earnhard +earnie +earps +eartha +earvin +easaw +eason +easson +easter +easterli +eastick +eastland +eastman +easton +eastreg +eastus +eastwood +eaton +eaves +eb +eba +ebara +ebata +ebba +ebbingha +eben +ebeneser +ebenezer +eberhard +eberle +eberlin +ebert +eberto +ebonee +ebony +ebrahim +eby +echols +eckardt +ecker +eckert +eckhart +eckler +ecklund +eckstein +ecocafe +econ +ecroyd +ed +eda +edan +edd +eddi +eddie +eddins +eddisfor +eddy +ede +edee +edel +edeline +edelman +eden +eder +edey +edgar +edgard +edgardo +edgette +edgreen +edi +edie +edif +edik +edin +edison +edistix +edita +edith +editha +edithe +ediva +edkins +edlene +edlin +edmison +edmon +edmond +edmonds +edmondso +edmonton +edmund +edmundo +edmunds +edmx +edmxtest +edna +edouard +edric +edsel +eduard +eduardo +educatio +eduino +edvard +edward +edwards +edwige +edwin +edwina +edwins +edy +edyta +edyth +edythe +efdal +effie +efland +efrain +efrem +efren +efron +efstrati +efthim +efthimio +eftychio +egan +egashira +egbert +egdorf +egerman +eggebraa +eggers +eggersgl +eggleton +egional +egli +egne +egner +egon +egor +ehab +ehi +ehlers +ehninger +ehrenfri +ehrenhol +ehrlich +ehrlichm +eicher +eide +eierstoc +eiji +eike +eiki +eiko +eileen +eilis +eimer +eimile +einarsso +einersen +einstein +einwohne +eirena +eirik +eisele +eisen +eisenach +eisenber +eisenhar +eisler +eisner +eisnor +eiswirth +eitner +ekaterin +ekiert +el +el-am +el-gueba +el-hawar +el-torky +eladio +elaina +elaine +elam +elana +elane +elayne +elbert +elberta +elbertin +elbeze +elbi +elchakie +elda +elden +eldin +eldon +eldoris +eldredge +eldreth +eldridge +eleanor +eleanora +eleanore +elec +electra +electric +electron +eleen +elefteri +elefther +elena +elene +eleni +elenore +eleonora +eleonore +elery +eleta +elex +eley +elfie +elfreda +elfredia +elfrida +elfrieda +elga +elgar +elgie +elgin +elhage +elhamahm +elhamy +eli +elia +elianora +elianore +elias +elicia +elie +eliezer +elihu +elijah +elin +eline +elinor +elinore +elio +eliot +elisa +elisabet +elise +eliseo +elisha +elissa +elita +eliud +eliza +elizabet +elizalde +elka +elkaim +elke +elkhayat +elkind +elkingto +elkins +elks +ella +ellacott +elladine +ellary +elle +elledge +elleke +ellement +ellen +ellene +ellens +eller +ellerey +ellerman +ellery +ellette +elli +ellie +ellinger +ellingto +elliot +elliott +ellis +ellison +ellissa +ello +ellryne +ellswert +ellswort +ellul +ellwood +elly +ellyn +ellynn +elmar +elmer +elmira +elmo +elmore +elms +elna +elnar +elnora +elnore +eloisa +eloise +elonore +elora +elpida +elroy +els +elsa +elsbeth +else +elset +elsey +elsi +elsie +elsing +elsinore +elson +elspeth +elston +elsworth +elsy +elting +elton +eluned +elva +elvera +elvert +elvin +elvina +elvira +elvis +elvyn +elwin +elwira +elwood +elwyn +ely +elyn +elyse +elysee +elysha +elysia +elyssa +elza +elzbieta +elzer +em +ema +emad +emalee +emalia +emami +emanatia +emanuel +emanuele +emdin-sp +emelda +emelen +emelia +emelina +emeline +emelita +emelyne +emer +emera +emerick +emerson +emery +emesh +emhart +emig +emil +emilda +emile +emilee +emili +emilia +emilie +emilien +emiline +emilio +emilios +emily +emlen +emlyn +emlynn +emlynne +emma +emmalee +emmaline +emmalyn +emmalynn +emmanuel +emmeline +emmell +emmerich +emmersto +emmert +emmery +emmet +emmett +emmey +emmi +emmie +emmit +emmons +emmott +emmy +emmye +emogene +emond +emory +emowilli +emp +empdb +employee +emr +emran +emrick +emro +emyle +emylee +emysta +encomend +endang +ende +endenbur +enderle +enders +enderton +endicott +endless +endot +endrys +endsley +enet +eng +eng-sion +engbert +engel +engelber +engelbre +engelhar +engin +engineer +england +englande +engle +engleber +englebri +engleman +englert +english +engman +engr +engracia +engsiong +engtv +enid +enis +ennis +enno +enns +enoch +enos +enrica +enrichet +enrico +enrika +enrique +enriquet +ensign +ensing +ensminge +ensor +enstone +entwistl +enver +environm +envoy +enzo +eoin +eolanda +eolande +eow +eperjesy +ephraim +ephrayim +ephrem +eppensti +epperson +eppich +epplett +epps +eprom +epstein +epting +eran +erasmus +erastus +erbach +erbilgin +erda +erdem +erdinc +erek +erena +erfani +ergle +erguven +erh-huan +erhard +erhart +erhhuan +eric +erica +erich +ericha +erichsen +erick +ericka +erickson +erics +ericsson +erie +erik +erika +eriks +eriksson +erin +erina +erine +erinn +erinna +erkan +erkel +erl +erland +erle +erlene +erler +erling +erma +ermanno +ermarkar +ermengar +ermentru +ermey +ermin +ermina +erminia +erminie +ermo +erna +ernaline +ernest +ernesta +ernestin +ernesto +ernestus +ernie +erning +ernst +erny +eroler +eros +errchend +errick +errol +erroll +ersch +ersil +erskine +ertan +ertha +ertl +erv +ervi +ervin +erwei +erwin +erwing +eryn +erzsebet +es +esam +esc +esch +eschen +escher +escherma +escobedo +escobido +escutin +esdras +esgate +esguerra +eshelman +eshghi +esi +esite +eskew +eskiciog +eskildse +esko +eslambol +esler +esliger +esma +esmail +esmaili +esmaria +esme +esmerald +esmond +esparza +espenson +espinosa +espinoza +esposito +espuna +esra +esry +essa +essam +esselbac +esser +essery +essie +essig +esson +essy +esta +estabroo +este +esteban +estegham +estel +estele +estell +estella +estelle +estep +ester +estes +estevam +estevan +estey +esther +estrella +estrelli +estridge +eswara +etan +etas +etchieso +etemad +eteminan +ethan +ethe +ethel +ethelber +ethelda +ethelin +ethelind +etheline +ethelred +ethelyn +ethier +ethingto +ethnolog +ethyl +etienne +etoh +etta +etten +etti +ettie +ettore +ettridge +ettson +etty +etu +etzell +eu +eubanks +euclid +eudora +euell +eugen +eugene +eugenia +eugenie +eugenio +eugenius +eugine +eula +eulalie +euler +eunchae +eung +euni +eunice +eunji +euphemia +eustace +eustacia +eustis +euy-soo +euysoo +euysung +ev +eva +evaleen +evalyn +evan +evandro +evangeli +evangelo +evania +evanne +evans +evanston +eve +eveleen +eveleigh +evelien +evelin +evelina +eveline +evely +evelyn +evelyne +even +evenson +events +everard +evered +everett +everette +everitt +evers +evert +evette +evey +evia +evie +evin +evita +evon +evona +evonne +evraire +evren +evvie +evvy +evy +evyn +ewald +ewan +ewanchyn +eward +ewart +ewasyshy +ewell +ewen +ewing +exner +ext +eyde +eydie +eyers +eyk +ezechiel +ezekiel +ezella +ezequiel +eziechie +ezmerald +ezra +ezri +ezzat +fab +fabe +fabella +faber +fabian +fabiano +fabien +fabienne +fabijani +fabio +fabris +fabrizio +fabry +facchett +facility +fadel +fadhel +fadi +fadj +fadlalla +fady +fadzilah +fae +faez +fafa +fafara +fagan +fagg +fagin +fahey +fahim +fahrenth +fahy +fai +faina +fainaru +fainecos +faiq +fair +fairclou +fairfax +fairfiel +fairleig +fairless +fairlie +fairman +fairy +faisal +faison +fait +faith +faiz +faizal +fajardo +falaki +falardea +falbee +falcao +falconer +faletti +faley +falicov +falito +falke +falkenst +falkner +fallah +fallahi +falletti +fallis +fallon +fallows +falquero +falt +faltens +fambroug +familiad +famke +fan +fanchett +fanchi +fanchon +fancie +fancy +fanechka +fang +fangio +fani +fania +fann +fanner +fanni +fannie +fanny +fansher +fantauzz +fanthome +fanty +fanus +fanya +faou +far +fara +faraday +farag +farago +farah +farahvas +faramarz +farand +farant +fares +fargis +fargo +farhad +farhan +farhang +farhat +farias +fariba +fariborz +farica +farid +faris +farlay +farlee +farleigh +farley +farlie +farly +farmer +farn +farnham +farnjeng +farnswor +farnum +farokh +farooa +farooq +farouk +farquhar +farr +farra +farrah +farrand +farranto +farrel +farrell +farren +farringt +farris +farrokh +farronat +farrow +farrukh +farshid +faruk +faruque +farzad +farzin +fasken +fast +fastfeat +fastmer +fastone +fastowl +fastpack +fataneh +fater +fatholla +fatica +fatima +fattarus +fattouh +faubert +faucette +faucher +faulhabe +faulkner +faun +faunie +faust +faustina +faustine +fausto +faustus +favell +favreau +favrot +fawaz +fawcett +fawn +fawne +fawnia +fax +fay +fayanne +faydra +faye +fayette +fayez +fayina +fayma +fayre +fayth +faythe +faz +fazel +fearless +featherm +feddeman +fedderse +feder +federica +federico +federiko +fedora +fedoruk +fedyk +fee +feeley +feeney +fehr +fei +fei-wen +fei-yin +feil +feild +feisal +feist +feitel +feith +feiwen +fekade +fekri +felczak +feld +feldberg +felder +feldman +felecia +felfli +felic +felicdad +felice +felicett +felicia +felicio +felicity +felicle +felike +feliks +felipa +felipe +felisha +felita +felix +feliza +felizio +felli +fellman +felske +feltman +felton +femke +fenati +fender +fenelia +fenez +feng +fenlason +fenn +fennell +fenner +fennesse +fenton +fenwick +feodor +feodora +fequiere +ferba +ferd +ferdie +ferdinan +ferdy +feregyha +fereidoo +ferelith +ferenc +ference +ferenz +fererro +fergus +ferguson +fergusso +feridoun +ferland +fermat +fermi +fermoyle +fern +fernald +fernan +fernand +fernanda +fernande +fernandi +fernando +ferne +ferner +ferrao +ferrara +ferraro +ferree +ferreira +ferrel +ferrell +ferrer +ferrero +ferriera +ferrin +ferris +ferriss +ferro +ferruzzi +ferstl +fetterma +fetting +fetzko +feutlins +fevre-re +fey +feyen +feynman +fi-john +fiann +fianna +fiaz +ficco +ficici +ficken +ficker +fickes +fidel +fidela +fidelia +fidelio +fidelity +fidole +fiegel +fieke +field +fielden +fielding +fields +fieldsup +fierthal +fiest +fifi +fifield +fifine +figura +fijohn +fikis +fikre +fil +filbert +filberte +filberto +fildey +filer +files +files ar +filia +filibert +filide +filion +filip +filippa +filippi +filippo +filis +filkins +fillmore +filmer +filmore +filpus +filson +fima +fin +fina +finak +finance +finane +finckler +findlay +findley +finkhels +finlay +finlayso +finley +finn +finnegan +finnerty +finney +finnie +finnigha +finnon +fintan +finucane +finzel +fiona +fionan +fionna +fionnula +fiore +fiorenze +fiorile +firas +firat +firdaus +firerobi +firment +firtos +fischer +fischett +fischler +fiset +fisette +fishenco +fisher +fishkin +fishman +fisico +fisopn +fisprod +fiszman +fitch +fiteny +fitness +fitz +fitzgera +fitzgibb +fitzpatr +fitzroy +fitzsimm +fixsen +flach +flagg +flaherty +flanagan +flanders +flann +flanner +flansbur +flatley +fleet +fleig +fleische +fleishma +flem +fleming +flemming +fleskes +fletch +fletcher +fleuchau +fleugel +fleur +fleurett +fleurima +fleury +flewelli +flexo +flicking +flin +flindall +flinn +flint +flintall +flo +floch +flook +flookes +flor +flora +florance +florante +flore +florella +florence +florenci +florenti +florenza +flores +florescu +florette +florez +flori +floria +florian +florida +florie +florina +florinda +florine +floris +florjanc +florri +florrie +florry +flory +flossi +flossie +flossy +flounder +flowers +floyd +floysvik +flss +fludgate +flueckin +fluet +fluney +flury +fluty +flying +flynn +foad +fobert +focht +focsanea +focus +fodell +foderaro +foeppel +foessl +foest +fogelson +foght +fogle +fogleman +fok +folashad +foldes +foley +follett +follick +follmer +folwell +fon +fondacar +fong +fonnie +fons +fonsie +fontaine +fontana +fontanil +fontanin +fony +fonz +fonzie +foods +foong +foos +forbes +forbrich +forbs +ford +forden +fordham +forecast +foreman +forese +forest +forester +forgeron +forghani +forgues +forland +formagie +forno +forouhar +forrest +forreste +forrette +forslund +forst +forster +forsythe +fortes +fortier +fortman +fortner +foss +foster +fothergi +fotini +fouad +foubert +foucault +fouchard +fougere +fouillar +fouke +fouletie +foulkes +four +fouret +fourier +fourkas +fournel +fourney +fournier +fouts +fowler +fowler-h +fowles +fowlkes +fowlston +fox +foxworth +fpsched +fqa +fraanky +fradette +fragnito +fraley +fralick +fralix +frampton +fran +franc +france +francene +frances +francesc +francese +franchot +francic +francine +francis +francisc +francisk +francisp +franckli +franckly +francky +franco +francoeu +francois +francyne +frangoul +franics +frank +franka +frankcom +frankenb +franki +frankie +frankle +franklin +franklyn +frankos +franks +franky +franni +frannie +franny +frans +fransis +fransisc +frantise +frants +frantz +franz +franza +franze +franzen +franzky +franzwa +frape +frasco +fraser +frasier +frasquit +fraties +frayda +fraze +frazer +frazier +fred +freda +freddi +freddie +freddy +fredek +fredelia +fredenbu +frederic +frederig +frederik +frederiq +fredette +fredi +fredia +fredimos +fredine +fredra +fredric +fredrick +fredrika +fredriks +free +freeburn +freedman +freek +freeland +freeley +freeman +freemand +freemon +freeth +freiberg +freida +freimark +freire +freiwald +freixe +freksa +fremont +french +frendo +frenette +freno +fretz +freud +frey +freya +freyermu +freyler +frezzo +fricker +fricks +fridel +frie +frieda +friedber +frieder +friederi +friedl +friedlan +friedman +friedric +frierson +friesen +frinel +frink +frisa +frischkn +frischli +frisk +friton +fritz +fritzie +frizado +frobel +froberg +frobishe +frodsham +froehlic +froncek +frondozo +fronsee_ +fross +frosst +froud +froukje +frucci +fruehauf +frumerie +fruscia +fryar +frydach +frydman +fryer +fscocos +fssup +ftpsites +fu +fu-sheng +fu-shin +fu-zong +fuchs +fucito +fugen +fujii +fujimaki +fujimoto +fujiwara +fukui +fukumoto +fukunaga +fulford +fulk +fulkerso +fullager +fuller +fullmer +fullum +fulmer +fulton +fulvia +fumerton +fumio +funamoto +funderbu +fung +funston +fuping +fuqua +furdoonj +furgerso +furlin +furlow +furmania +furnas +furrukh +furst +furuta +fusca +fusheng +fuson +fussell +fuzal +fuzong +fwp +fwpas +fwpco +fwpreg +fwptools +fyfe +fysh +fyske +gaal +gabato +gabbai +gabbard +gabbey +gabbi +gabbie +gabby +gabe +gabey +gabi +gabie +gaboury +gabriel +gabriela +gabriele +gabriell +gabrila +gaby +gach +gaconnie +gadbois +gadher +gadouchi +gadsby +gadzinow +gae +gaebel +gael +gaelan +gaertner +gaetan +gaetanin +gaetano +gaffney +gafford +gaftea +gagan +gage +gagne +gagnier +gagnon +gahan +gahir +gahlot +gahn +gahr +gahunia +gaiarsa +gaiger +gail +gaile +gailya +gaime +gainer +gaines +gaiotti +gaiser +gaitan +gaither +gajendra +gajewski +gajowiak +gal +galanaki +galasso +galbrait +galdwin +gale +gale +galen +galewski +galina +galipeau +gallaghe +gallais +gallard +gallegos +gallenbe +galligan +gallinge +gallion +gallman +gallo +gallops +gallouzi +galloway +galluzzi +galois +galt +galvan +galven +galvez +galvin +gama +gamal +gamaleld +gamaliel +gamarnik +gambrell +gamelin +gammage +gamsa +gan +ganadry +ganapath +gandhi +gane +ganesan +ganesh +ganeshku +gangnes +gangotra +ganguly +gani +gann +ganness +gannett +gannie +gannon +gannot +ganny +gans +gant +gantt +gapp +gar +gara +garald +garamvol +garan +garand +garay +garbis +garbish +garcia +garcia-l +garcia-m +gard +gardener +gardie +gardiner +gardner +gardy +gare +garee +gareis +garek +gareth +garey +garfield +garg +garguilo +gargul +gargulak +garik +garinger +garito +garland +garmon +garneau +garner +garnet +garnett +garnette +garney +garo +garold +garp +garrard +garrek +garret +garreth +garrett +garrick +garrik +garrot +garrott +garry +garth +gartley +gartshor +garv +garvey +garvin +garvy +garwin +garwood +gary +gascho +gascon +gasikows +gaskins +gaspar +gaspard +gasparo +gasparot +gasper +gass +gast +gaston +gasul +gateau +gateley +gater +gates +gateway +gatka +gattrell +gau-rong +gaube +gaudet +gaudet-m +gaudon +gaudreau +gaughan +gaul +gaulle +gault +gaultier +gaunsezl +gaurong +gause +gauss +gautam +gauthier +gav +gavan +gaven +gavens +gavidia +gavilluc +gavin +gavra +gavriel +gavriell +gawain +gawargy +gawdan +gawen +gawronsk +gawtrey +gay +gaye +gayel +gayelord +gayl +gayla +gayle +gayleen +gaylene +gayler +gaylor +gaylord +gayman +gaynor +gayronza +gazala +gazier +gazo +gdowik +ge +geadah +gean +gearalt +gearard +geary +gebhardt +gebhart +gebrael +gedas +geddes +gedeon +gedman +gedra +gedye +gee +gee-meng +geer +geert +geesman +geeta +geetha +geety +gehm +gehr +gehring +geiger +geir +geisler +geksong +gelais +geldrez +gelinas +gell +geller +gelling +gelo +gelya +gemmill +gen +gena +genae +gendre +gendron +gene +geneau +general +generalc +generato +genet +geneva +geneviev +genevra +genga +genge +genia +genie +genna +gennaro +genni +gennie +gennifer +genny +geno +genova +genovera +genovise +genowefa +gentes +gentzler +genvieve +geoff +geoffrey +geoffrio +geoffry +georas +geordie +georg +georgann +george +georgean +georgena +georges +georgesc +georgeta +georgett +georghio +georgi +georgia +georgian +georgie +georgina +georgine +georgio +georgiou +georgy +ger +gera +gerald +geralda +geraldin +geralene +gerard +gerardja +gerardo +gerassim +gerbec +gerben +gerber +gerda +gerek +gerenser +gergen +gerhard +gerhardi +gerhardt +gerhart +geri +gerianna +gerianne +gerick +gerik +gerladin +gerlich +gerlinsk +gerlt +germ +germain +germaine +germana +germano +germayne +germe +gernot +gerome +gerrard +gerri +gerrie +gerrilee +gerrit +gerritse +gerry +gershwin +gerstmar +gert +gerta +gerth +gerti +gertie +gertridg +gertrud +gertruda +gertrude +gertrudi +gerty +gervais +gervaise +gery +gerynowi +gesine +gesino +gessford +getchell +getoor +gettys +geuder +gewell +geyer +geza +ghadisha +ghaemi +ghaemian +ghaffari +ghandi +ghanem +ghangurd +ghani +ghantous +ghartey +ghasemia +ghassan +ghassem +ghatta +ghazi +gheciu +ghelardu +gheorghe +gherardo +ghidali +ghislain +ghobad +gholamre +ghorashy +ghosh +ghossein +ghulam +ghulati +gia +giacinta +giacobo +giacomo +giacopo +giallo +giamatte +giambatt +giambera +giampaol +gian +giana +giandome +giang +giani +gianina +gianna +gianni +giao +giap +giarritt +giavani +gib +gibb +gibbie +gibbins +gibbons +gibbs +gibby +gibeault +giblin +gibson +gidaro +gideon +gidget +gie-ming +giekes +gieming +gierka +giertych +giesbrec +gieschen +giese +giff +giffard +giffer +giffie +gifford +giffy +giggey +gigi +giguere +gigus +gihan +gihyun +gil +gilbert +gilberta +gilberte +gilberti +gilberto +gilberts +gilburt +gilchris +gilda +gilemett +giles +giliham +gill +gillan +gillard +gille +gillelan +gilles +gillespi +gillespy +gillet +gillette +gilli +gilliam +gillian +gilliard +gillie +gillies +gillig +gilligan +gillilan +gillis +gillon +gillot +gillstro +gilly +gilmore +gilmour +gilstorf +gimon +gin +gina +ginelle +ginest +ginette +ginetto +ginevra +ginger +gingeric +gingold +gingras +gingrich +gini +ginn +ginni +ginnie +ginnifer +ginny +gino +ginsberg +gint +gintaras +ginzburg +gio +gioffre +gionet +giordano +giorgi +giorgia +giorgio +giorgos +giotis +giovanna +giovanni +giovinaz +gipsy +giralda +giraldo +girard +giraud +girgis +giri +giridhar +girish +girotti +girouard +giroux +girvan +gisbert +gisela +giselber +gisele +gisella +giselle +gita +gittins +giuditta +giuhat +giulia +giuliani +giuliett +giulio +giuntini +giuseppe +giustina +giustino +giusto +gize +gizela +glad +gladi +gladstei +gladys +glancey +glanfiel +glaros +glasa +glaser +glasgow +glass +glasser +glast +glaszcza +glazer +gleason +gleda +gleditsc +glembosk +glen +glenda +glenden +glendon +glenine +glenn +glenna +glennie +glennis +glew +glickman +glidewel +glinka +glinski +glofches +glori +gloria +gloriana +gloriane +glornia +glory +glover +glowa +glucksma +glymph +glyn +glynda +glynis +glynn +glynnis +gnaeding +gnni +go +goangshi +goatcher +goba +gobeil +gobeli +goble +gockel +godard +godart +godcharl +goddard +goddart +godden +goddette +godfree +godfrey +godfry +godin +godina +godish +godiva +godley +godlingt +godo +godowsky +godse +godsoe +godwin +goei +goel +goell +goeltzen +goerss +goertz +goertzen +goethe +goetz +goff +gofron +goggin +goh +goheen +goin +goins +gokal +gokul +gokul-ch +golari +golas +golaszew +golczews +golda +goldarin +goldberg +goldenbe +goldenso +golder +goldfiel +goldi +goldia +goldie +goldina +goldman +goldmann +goldner +goldschm +goldstei +goldthor +goldwyn +goldy +golia +goliss +golka +goller +gollu +golshan +gombos +gomes +gomez +gomm +gong-lia +gonglian +goniotak +gonsalve +gonzaga +gonzales +gonzalez +gonzalo +goober +gooch +goodbar +goode +gooderha +goodfell +goodier +goodinso +goodman +goodner +goodridg +goodrow +goodson +goodwin +goofy +goold +gooley +goos +gopal +gopaul +gope +gopisett +goran +gorasia +goraud +gorberg +gord +gordan +gorde +gorden +gordie +gording +gordon +gordy +gorenflo +gores +gorfine +gorham +gorhum +goricane +goridkov +goring +gorius +gorlick +gorman +gorsky +gorton +gorzocos +goska +goss +gosselin +gosset +gostania +goswick +goszczyn +gotch +gotchall +goth +gothard +gothart +gottfrie +gottlieb +gottscha +gottstei +gou-don +goudon +goudreau +gougeon +gough +gouhara +goukon +gould +gouldson +goulet +goulette +goulfine +goupil +gourley +goutam +govind +govindan +govindar +govindas +gow-jen +gowan +gowda +gowens +gower +gowin +gowjen +gowl +gowland +goyal +goyer +goyette +goza +gozani +gozen +grabner +grabowsk +grace +gracen +gracey +gracia +gracie +graciela +gracinda +gracomda +gradeigh +grader +gradey +grading +grads +grady +graehme +graeme +graessle +graff +grafton +graham +graibe +graig +grainger +gram +graman +grame +gramiak +gran +granado +granata +grandboi +grande +grandmas +grandump +grandy +granfiel +grange +granger +granic +granner +grannie +granny +grant +grantham +granthem +grantley +granvill +graphics +grasman +grason +grassman +grata +gratia +gratiana +gratton +grau +grauer +grausso +gravelle +gravely +graver +graves +gravitt +gravitte +grawberg +gray +graybill +grayce +graydon +grayson +grazia +graziano +grazzini +greaney +greatest +greaver +greaves +grebil +grebner +greco +greeley +greenber +greene +greenfie +greenlee +greenstr +greenway +greer +greet +greg +gregaric +greger +gregg +gregge +greggory +grego +gregoire +gregoor +gregor +gregor-p +gregorio +gregoriu +gregorsk +gregory +grelck +grenier +grenon +grenvill +greszczu +gret +greta +gretal +gretchen +grete +gretel +grethel +gretna +gretta +grevelin +grevy +grewal +grey +greytock +gribbon +gribbons +grier +griet +grietje +griff +griffie +griffin +griffioe +griffith +griffy +grigg +griggs +grignon +grigsby +grillmey +grills +grimble +grimes +grimm +grimmell +grimshaw +grimsley +griner +grinham +grinnell +gris +griselda +grisoni +grissel +grissom +griswold +gritton +grixti +griz +groce +grochau +grodecki +groetsem +groff +grogan +grohovsk +groleau +grona +grondin +gronwall +grooms +grootenb +gros +grosh +grosjean +grosman +grosse +grossman +grossutt +groth +groulx +grove +grover +groves +grovesti +growden +growler +gruau +grubbs +gruber +grueneic +grueng +gruenhag +grund +gruska +gruszczy +gryder +grzegore +grzegorz +grzesik +gsite +gu +guajardo +gualteri +guan +guanglia +guangyou +guanyun +guarez +guarino +guarnera +guatto +guay +gubbins +gubenco +gucer +guciz +gudgeon +gudrun +guendole +guenette +guenever +guenna +guenther +guercion +guerette +guerin +guerrero +guerrier +guertin +guests +guevara +guglielm +gui +guido +guignon +guilbaul +guilbert +guilford +guilfoyl +guillaum +guillema +guilleme +guillerm +guillet +guillory +guilmett +guimond +guin +guindi +guindon +guinever +guinn +guinna +guinnane +guiqing +guirguis +guisler +guitard +guitaris +gulbrand +gulick +gulis +gulko +gullekso +gultekin +gulvin +gumb +gumbley +gummadi +gumperz +gun +gunadhi +gunar +gunars +gunaseke +gunawan +gundecha +gunderse +gunderso +gundes +gundlach +gundry +guner +gunfer +gung +gungor +gunilla +gunkel +gunn +gunnar +gunnells +gunner +gunshor +guntar +guntekin +gunter +gunther +guntvedt +guo +guo-jie +guo-qian +guoben +guochun +guojie +guoming +gupta +gupton +gur-arie +gurash +gurchara +gurdip +gure +gurer +gurevitc +gurgenci +gurica +gurjinde +gurjit +gurley +gurmeet +gurnam +gurney +gursahan +gurshara +gursin +gurvinde +gus +gusella +guss +gussi +gussie +gussy +gusta +gustaf +gustafso +gustafss +gustav +gustave +gustavo +gustavus +gusti +gustie +gustlin +gusty +gutcher +gutermut +guth +guthrey +guthrie +guthro +guthry +gutierre +guttman +guty +gutzmann +guy +guy-arbo +guylain +guylaine +guyot +guzman +gwen +gwenda +gwendole +gwendoli +gwendoly +gweneth +gwenette +gwenneth +gwenni +gwennie +gwenny +gwennyth +gwenora +gwenore +gwo-chun +gwo-hsin +gwochung +gwohsing +gwyn +gwyneth +gwynith +gwynne +gyenes +gyeongbe +gyger +gylys +gyoung +gypsy +gysel +gyula +gyurcsak +gyurcsik +gzl +ha +haack +haaksman +haas +habeeb +habel +habelrih +haber +haberman +habert +habib +hachador +hache +hachelle +hachey +hack-hoo +hacker +hackett +hacking +had +hadaway +haddad +hadden +haddow +hadel +hadi +hadiraha +hadlee +hadleigh +hadley +hadria +hadrian +hady +hadziome +hae-won +haerle +haertel +haether +haewon +hafedh +hafeezah +hafermal +hafiz +hafleigh +hagan +hagar +hage +hagen +hagenbuc +hager +hagerty +hagewood +haggar +haggart +haggarty +haggerty +hagglund +haghighi +hagley +hagstrom +hagwood +hahn +hai +hai-ning +hai-ping +hai-shun +haibo +haifang +haig +haigh +hailee +hailes +hailey +haily +haim +haimson +hainer +haines +haining +hainline +haiping +haire +hairil +haishung +hak-lay +hakala +hakan +hakansso +hakeem +hakim +haklay +hal +hala +halbedel +halbert +hale +haleigh +halejak +halet +halette +haley +half +halford +hali +halicki +halie +halimeda +halina +hall +hallamas +hallenbe +haller +hallett +halley +halli +hallie +halligan +halliwil +hallman +hallsy +hally +halovani +halpenny +halpern +halpin +halsey +halstead +halsy +haluk +halula +ham +hamachi +hamavand +hambali +hambone +hamdy +hameed +hamel +hamelin +hamid +hamidi +hamil +hamilton +hamish +hamlen +hamlett +hamlin +hamliton +hammad +hammel +hammerli +hammerme +hammond +hammonds +hamner +hamnet +hamori +hamoui +hampel +hampshir +hampson +hampton +hamra +hamsa +hamzeh +han +han-chie +han-co +han-fei +han-tak +han-van +hana +hanan +hanchieh +hanco +hancock +handel +handfort +handley +handoko +handschy +hane +hanel +haney +hanfei +hanford +hang-ton +hangbok +hanger +hangup +hanh +hanham +hanhb +hanhua +hani +haningto +hanja +hank +hankins +hanlan +hanley +hann +hanna +hannah +hanneke +hanneman +hanni +hannibal +hannible +hannie +hannis +hanns +hannula +hanny +hanrahan +hans +hans-pet +hansen +hanser +hansiain +hanson +hanspete +hansquin +hansraj +hansson +hantak +hanzel +hanzlice +hao +hao-nhie +hao-yung +haonhien +haoyung +happy +harabedi +harada +haralamb +harald +harapiak +harbert +harbord +harbottl +harbour +harcourt +hardage +hardcast +hardee +harderse +hardi +hardiman +hardin +harding +hardison +hardman +hardwick +hardy +hardyal +hardyck +hardyman +haren +hareton +hargadon +hargreav +hargrove +hargrow +hari +harianto +harihara +hariman +harinder +harish +harishan +harker +harkness +harlan +harland +harlen +harlene +harles +harless +harley +harli +harlie +harlin +harm +harman +harmeet +harmi +harmon +harmonia +harmonie +harmony +harms +harn +harold +haroon +harootun +haroun +haroutou +harp +harpal +harpe +harper +harpreet +harrawoo +harrell +harri +harrie +harriet +harriett +harringt +harriot +harriott +harris +harrison +harrod +harron +harry +harsch +harshad +harsham +harshava +harshfie +hart +harte +hartell +harter +hartford +hartgrov +hartin +hartkopf +hartland +hartleb +hartley +hartling +hartman +hartmann +hartmut +hartney +hartsell +hartwell +harty +hartzel +haruko +harv +harvard +harvey +harville +harvison +harwell +harwerth +harwilll +harwood +hasan +hasbrouc +hasegawa +hasen +hasham +hasheem +hashem +hashemi +hashim +hashimot +haskel +haskell +haskins +haslach +hasler +haslett +hasmukhb +hasnain +hassan +hassenkl +hassey +hassnzah +hassold +haste +hasted +hastic +hastie +hastings +hasty +hata +hatcher +hatchett +hately +hatfield +hathaway +hatridge +hattar +hatten +hatti +hattie +hattingh +hatty +hatz +hatzenbi +hau +haubert +hauck +hauersto +haufe +hauge +haughey +haughwou +haugrud +haupt +haurie +hause +hauser +hautanen +havelock +haveman +haven +haverkam +haverty +havis +hawes +hawi +hawk +hawken +hawker +hawkes +hawkin +hawkins +hawley +hawryluk +hawrysh +hawryszk +hawthorn +hayden +haydock +haydon +haydt +hayes +hayley +haylock +hayman +haynes +haynor +hayward +haywood +hayyim +haze +hazel +hazeldin +hazell +hazelrig +hazelton +hazem +hazen +hazenboo +hazlett +hdbright +hdi +he +heald +healey +heall +health-s +healy +heaney +hearn +hearnden +hearst +heath +heather +heaton +hebbar +hebe +hebert +heckbert +heckman +hector +heda +hedda +heddell +heddi +heddie +heddy +hedi +hedin +hedke +hedman +hedrich +hedrick +hedvig +hedvige +hedwig +hedwiga +hedy +hee +heeralal +heeten +hefferna +heffner +hegarty +hegelian +hehn-sch +heida +heide +heidebre +heidepri +heidi +heidie +heighton +heike +heikkila +heile +heilig +heiliger +heilsnis +hein +heindric +heinen +heinjus +heinke +heino +heinonen +heinrich +heinrick +heinrik +heinz +heinzing +heinzman +heisler +heitmann +hekel +heki +helaina +helaine +heldenbr +heleen +helem +helen +helen-el +helena +helene +heleneli +helenka +helfrick +helga +helge +helgelan +helio +helkaa +hella +hellberg +hellen +heller +hellerst +helli +hellmut +helluva +hellyer +helma +helms +helmut +helmuth +helmy +heloise +helpb +helpline +helsa +helseth +helstab +helton +helwege +helyn +hemant +hembrick +hemens-d +hemme +hemmerle +hemphill +hempinst +hempstea +henao +hench +henderso +hendra +hendren +hendrick +hendrik +hendrika +hendriks +hendry +hendryck +hendy +henein +heng +hengameh +hengda +hengevel +hengl +hengst +henk +henk smi +henka +henley +henline +henneber +hennebur +hennelly +hennessy +hennie +henninge +hennon +hennriet +henny +henri +henrie +henrieta +henriett +henrik +henrika +henrikse +henry +henryett +hensen +henshaw +hensley +henson +henstock +henthorn +hepburn +hephziba +heping +heppell +heppes +hera +herak +herb +herbel +herberge +herbers +herbert +herbie +herby +herc +hercule +hercules +herculie +here's t +heredia +heribert +hering +herlihy +herling +herm +hermack +herman +hermann +hermann- +hermanns +hermes +hermia +hermie +hermien +hermina +hermine +herminia +hermione +hermon +hermy +hernan +hernande +hernando +herndon +hernek +herner +herng-je +herngjen +hernon +hernzlia +herod +herold +heroux +herr +herrage +herralio +herre +herren +herrera +herrick +herring +herringt +herriott +herrmann +herron +herronal +herryjan +hersch +herschel +herscovi +hersee +hersh +hershber +hershel +herskovi +herta +hertha +hertler +hertzog +herve +hervey +herzig +hesche +hesham +hesk +hesketh +heslop +hess +hesse +hester +hesther +hestia +hetti +hettie +hetty +hetzel +heung +heunis +heurich +hew +hewage +hewe +hewer +hewet +hewett +hewie +hewitt +hewlet +hews +heybroek +heydon +heyer +heynen +heys +heystrae +heyward +heywood +hi +hiawatha +hibberd +hibler +hichem +hickerso +hickey +hickin +hickman +hickman- +hickox +hicks +hidaka +hideki +hideo +hiebsch +hien +hienz +hieronym +hiers +higginbo +higgins +higham +highet +highsmit +hight +hightowe +higuchi +hijab +hikita +hil +hilaire +hilario +hilarius +hilary +hilberma +hilbert +hilbig +hilda +hildagar +hilde +hildebra +hildegaa +hildegar +hilder +hildum +hildy +hilfinge +hill +hilla +hillard +hillary +hillel +hiller +hillery +hilliard +hilliary +hillidge +hillie +hillier +hillring +hills +hillson +hilly +hillyer +hilmi +hils +hilton +hiltz +hilwa +himanshu +himawan +himraj +hin-wai +hincher +hinchey +hinchley +hinda +hindle +hinds +hindson +hine +hiner +hines +hing +hing-fai +hingtgen +hink +hinkel +hinkins +hinkle +hinojosa +hinsdale +hinshaw +hinson +hinton +hinton-s +hinz +hinze +hipp +hippert +hipson +hirakawa +hiraki +hiram +hirayama +hiren +hirofumi +hirohama +hiroki +hiroko +hiromi +hiromu +hironaga +hirooki +hirose +hiroshi +hirotaka +hiroto +hirotosh +hiroyuki +hirsch +hirshman +hisaki +hiscoe +hiscott +hisham +hishchak +hisko +hislop +hitchcoc +hitching +hite +hitler +hitoshi +hiusser +hively +hixon +hixson +hjartars +hjorth +hlady +hlausche +hlinka +hm +hnidek +ho +ho-mu +hoa +hoa-van +hoadley +hoag +hoagland +hoang +hoare +hobard +hobart +hobbs +hoben +hobesh +hobey +hobgood +hobie +hobin +hoch +hochbaum +hochberg +hock +hockaday +hockster +hoctor +hocutt +hodder +hoddinot +hodedo +hodge +hodgens +hodges +hodgins +hodgkin +hodgkiss +hodgson +hoebart +hoeg +hoehling +hoehn +hoek +hoeksma +hoekstra +hoeler +hoelsche +hoequist +hoes +hoferek +hoffelt +hoffman +hoffmann +hoffmeis +hoffpaui +hoffsted +hofmann +hofmeist +hofstede +hofstett +hogan +hogeboom +hogg +hoggan +hoggatt +hogue +hohmeyer +hohn +hoi-kin +hojjat +holberry +holbrook +holcomb +holcombe +holcroft +hold of +holdaway +holden +holder +holdren +holesing +holinski +hollack +holland +hollande +hollands +hollbach +hollen +hollenba +hollenbe +hollenst +holleran +holley +holli +holliday +hollie +hollings +hollingt +hollingw +hollis +holliste +holloway +hollran +holly +holly-an +hollyann +holm +holman +holmans +holmer +holmes +holmquis +holness +holsclaw +holst +holt +holterma +holthaus +holton +holtz +holtze +holvey +holy +holz +hom +homa +homan +homayoon +homayoun +homer +homere +homerus +homonick +homu +hon +hon-kong +hon-son +honbarri +honda +honey +honeycut +hong +hong-che +hong-yuh +hongchen +hongtao +hongyuh +hongzhi +honkakan +honmun +honor +honoria +honson +honzo +hoog +hooi-lee +hooker +hooks +hoon +hooper +hoorman +hooshang +hooton +hoover +hopcroft +hope +hopf +hopkin +hopkins +hopkinso +hopley +hoppenwo +hopper +hopson +hoptoad +hoque +hor +hor-lam +horace +horacio +horak +horalek +horatia +horatio +horatius +horban +hord +hore +horemans +horgan +horianop +horkoff +hormoz +hornacek +hornbeck +hornbeek +hornburg +horne +horng +horngdar +horning +hornung +horowitz +horsfiel +horst +horstman +hort +horten +hortense +hortensi +horton +horus +horvath +horwitz +horwood +hosang +hosanna +hoscheid +hoseok +hoshi +hosier +hoskin +hosking +hoskins +hosneld +hossein +hosseini +hot +hotline +hotlist +hotson +hotta +houde +houdini +houghton +houk +houle +houn +hounsell +houssam +houssein +houston +hoverman +hovey +hovinga +how +how-kee +howald +howard +howarth +howat +howden +howe +howe-pat +howekamp +howell +howerton +howes +howey +howie +howlett +howley +howorth +howse +hoxie +hoy +hoyer +hoyt +hpldt +hpone +hq +hqs +hr +hrdata +hrenyk +hrinfo +hrubik +hruby +hrushowy +hruska +hrvatin +hsi +hsi-ho +hsiang +hsiao +hsiao-ch +hsiao-we +hsiao-yu +hsiaochi +hsiaosu +hsiaowei +hsiaoyun +hsieh +hsien +hsiho +hsin +hsin-li +hsin-shi +hsing +hsing-ju +hsinli +hsiung +hsketh +hspice +hsu +hsuan +hsueh +htd +hu +hua +hua-yuan +huai +huan +huan-yu +huanbo +huang +huasheng +huashi +huay-yon +huayuan +huayyong +hubal +hubbard +hubbell +hube +huber +huberman +hubers +hubert +huberto +hubey +hubie +hubley +huboi +hudai +hudak +huddlest +hudecek +hudepohl +hudgins +hudson +hudy +hudyma +huel-she +huelshen +huelsman +hueneman +huerta +huestis +huether +huetu +huey +huey-kuo +hueykuo +hufana +huffman +hugel +huggins +hugh +hughes +hughes-c +hughey +hughie +hughson +hugibert +hugo +hugues +huguette +huguin +huhn +hui +hui-chau +hui-neng +huib +huichaun +huifang +huineng +huiqi +huitt +huizhao +hukam +hulda +hulen +hulett +huligang +hulk +hulme +hulst +hultgren +hulversh +hulze +humbert +humberto +humboldt +hume +humenik +humenuk +humes +humfrey +humfrid +humfried +humiston +hummel +hummerst +humphrey +humphrie +hundries +huneault +hunfredo +hung +hung-kan +hung-win +hungkai +hungkang +hungle +hungquoc +hungwing +hunike +hunnicut +hunsberg +hunsucke +hunt +hunter +huntingt +huntlee +huntley +huo-yen +huong +huor +huot +huoyen +hupe +huppert +hurd +huret +hurf +hurlee +hurleigh +hurley +hurman +hurst +hurtado +hurteau +hurtubis +hurwitz +husain +husam +husarewy +husein +hussain +hussam +hussein +husser +hussey +hussien +hustin +huston +huszar +huszarik +hutchers +hutchin +hutching +hutchins +hutchiso +hutson +hutt +hutter +hutton +huub +huuliem +huxley +huy +huyen +huynh +huyvan +huzur +hvezda +hwa +hwajin +hwan +hwang +hwayong +hwei-lin +hy +hyacinth +hyatt +hydar +hyde +hyen +hyer +hyerle +hyers +hyjek +hylaride +hyman +hymie +hynda +hyndman +hynek +hyong-ju +hyongjun +hyonil +hyoungju +hyperspa +hyrne +hysler +hyslop +hyte +hyun +hyunchul +hyung +i-chao +i-ching +iacoviel +iacovo +iago +iain +ian +ianace +iannotti +iannozzi +iantaffi +ianthe +iaquinto +iarocci +ibach +ibarra +ibbie +ibby +ibntas +ibrahim +ibsen +iburg +ic +iceman +ichabod +ichao +iching +ichiro +ichizen +icy +icylyn +id +ida +idalia +idalina +idaline +ide +idell +idelle +idette +idris +idt +idus +ie +iem +ientile +iezzi +if anyon +ifact +ifill +iftekhar +ifti +igarashi +iggie +igglesde +iggy +ignace +ignacio +ignacius +ignatius +ignaz +ignazio +igor +iguchi +igusa +ihnat +ihor +ijaz +ijff +ike +ikeda +ikey +ikotin +ikram +ikuo +ilaire +ilan +ilana +ilario +ilda +ileana +ileane +ilene +ilic +ilise +ilk +ilka +illa +illamchi +illidge +illinois +ilmberge +ilona +ilovich +ilowski +ilsa +ilse +ilsup +ilwhan +ilya +ilyas +ilyess +ilysa +ilyse +ilyssa +ima +iman +imbemba +imelda +imhof +imi +immanuel +imming +imogen +imogene +imojean +impaglia +imran +imre +imtaz +imtiaz +in-beum +in-cheol +in-hwan +ina +inam +inamulla +inan +inanc +inbeum +ince +incheol +incze +ind +indahl +indar +independ +inderjit +indiana +indianaj +indira +indra +indy +ineke +ines +inesita +inessa +inez +info +info-man +infocent +ing +inga +ingaberg +ingaborg +ingamar +ingar +inge +ingeberg +ingeborg +ingelber +ingell +ingemar +inger +ingersol +ingie +ingle +ingleber +ingles +ingling +inglis +ingmar +ingo +ingra +ingram +ingres +ingrey +ingrid +ingrim +ingunna +ingvar +inho +inhulsen +inhwan +inigo +inm +inman +inna +innchyn +innes +inness +innis +inniss +innocent +inoue +inquire +inrig +inscoe +insp +inspecti +instal +installe +integ +integrat +intemann +interact +interfac +intihar +intplan +intune +inyoung +ioan +ioana +ioannes +ioannidi +ioannis +ioannou +iocca +iocntrl +iola +iolande +iolanthe +iona +ione +iorgo +iorgos +iormina +iosep +ioui +ip +ipadmin +ipokrati +ippolito +iqbal +ira +iradj +iraj +irani +irby +irc +ircbellc +irccar +ircinter +ircmarke +ircmer +ircmtl +ircstand +irean +ireland +irena +irene +irfan +irias +iribarre +irice +irick +irina +iris +irish +irissou +irita +irma +irv +irvin +irvine +irving +irwin +irwinn +is a cat +isa +isaac +isaacs +isaak +isabeau +isabel +isabelit +isabell +isabella +isabelle +isac +isacco +isador +isadora +isadore +isahella +isaia +isaiah +isak +isami +isbister +iseabal +isenor +isensee +isert +isfan +ishak +ishan +ishee +isherwoo +ishii +ishikida +ishimoto +isiahi +isidor +isidora +isidore +isidoro +isidro +isin +isip +isis +iskandar +iskender +iskra +isl +islam +isley +ismael +ismail +isobel +isoft +isolde +israel +issa +issam +issi +issiah +issie +issy +itac +itah +italo +italus +itaru +itas +itaya +itch +iteam +iteke +ito +its-eng +iu +iva +ivan +ivancevi +ivancic +ivanhoe +ivanoff +ivanyi +ivar +ive +iver +ivers +iversen +iverson +ives +ivett +ivette +ivey +ivie +ivo +ivona +ivonne +ivor +ivory +ivy +iwan +iwanyk +iwashita +iwona +iws +iyad +iyengar +iyer +iyun +izaak +izabel +izak +izbinsky +izchak +izique +izora +izumi +izuru +izzat +izzo +izzotti +izzy +j-franco +jaakkola +jaan +jabbari +jabez +jabir +jablonsk +jabreen +jacalyn +jacek +jacenta +jachym +jacinda +jacinta +jacintha +jacinthe +jack +jackelyn +jacki +jackie +jacklin +jacklyn +jackman +jackquel +jackson +jacky +jackye +jaclin +jaclyn +jacob +jacobo +jacobs +jacobsen +jacobson +jacque +jacqueli +jacquely +jacquene +jacques +jacquett +jacqui +jacquie +jacynth +jacynthe +jada +jade +jadwiga +jae +jae-koo +jae-whan +jaekoo +jaenen +jaewhang +jaffer +jag +jagan +jagat +jagath +jagatic +jagdev +jagdish +jager +jagernau +jagjeet +jagjit +jagla +jago +jagodzin +jagriti +jags +jahangir +jahromi +jai +jaijeet +jaikne +jailyn +jaime +jaimie +jaimin +jain +jaine +jak +jakab +jakabffy +jakb +jake +jakeman +jaki +jakie +jakim +jakob +jakola +jakstys +jakubows +jalaie +jalal +jalali +jalaliza +jalbert +jalilvan +jama +jamaal +jamal +jamaly +jaman +jamel +jamensky +jamer +james +james_mi +jamesett +jameson +jamesy +jamey +jami +jamie +jamieson +jamil +jamilah +jamill +jamima +jamin +jamison +jammal +jammie +jammu +jamnejad +jamroz +jamshed +jamshid +jamshidi +jan +jan-olof +jan-robe +jana +janak +janaki +janaratn +janaya +janaye +jancewic +jancovic +janczyn +janda +jande +jandy +jane +janean +janecka +janeczka +janeen +janek +janel +janela +janell +janella +janelle +janene +janenna +janes +janessa +janet +janeta +janeth +janetta +janette +janeva +janey +jang +jang-hsu +janghsue +jani +jania +janice +janick +janie +janifer +janina +janine +janio +janis +janiszew +janith +janka +janke +jankowsk +jann +janna +jannay +jannel +jannelle +jannie +janning +janolof +janos +janot +janovich +janrober +janseen +jansen +janson +janssen +jantz-le +jantzi +januario +janusz +jany +jap +japan +japp +jaquelin +jaquelyn +jaquenet +jaques +jaquith +jarad +jarboe +jarchow +jard +jareb +jared +jarel +jargon +jarib +jarid +jarl +jarlath +jarmal +jarman +jarmo +jarmoc +jarmon +jarmul +jarnak +jarrad +jarred +jarret +jarrett +jarrid +jarrod +jarvah +jarvie +jarvin +jarvis +jarzemsk +jasbinde +jase +jasen +jashvant +jasmann +jasmin +jasmina +jasmine +jasny +jason +jasper +jaspreet +jasrotia +jasti +jastinde +jasun +jasver +jaswal +jatar +jatin +jatinder +jau-min +jau-yau +jaumin +jauvin +jauyau +java +javad +javallas +javar +javed +javier +javor +jawad +jawaid +jawana +jawanda +jawor +jaworski +jaworsky +jay +jaya +jayakuma +jayamann +jayant +jayanta +jayavant +jaye +jayendra +jayesh +jayjay +jayme +jaymee +jaymie +jayn +jayne +jaynell +jaynie +jayshree +jayson +jazanosk +jazmin +jcbach +jcst +jdavie +jderek +jean +jean-ber +jean-cla +jean-den +jean-fra +jean-guy +jean-jac +jean-lou +jean-luc +jean-mar +jean-mic +jean-nor +jean-pau +jean-pie +jean-rob +jean-roc +jean-yve +jeana +jeane +jeanelle +jeanes +jeanette +jeanhee +jeanice +jeanie +jeanine +jeanloui +jeanna +jeanne +jeannett +jeannie +jeannine +jeannot +jeany +jeavons +jecho +jed +jedd +jeddy +jedediah +jedidiah +jedrysia +jee +jee-howe +jeff +jefferey +jefferso +jeffery +jeffie +jeffrey +jeffreys +jeffries +jeffry +jeffy +jegland +jehanna +jehovah +jehu +jelen +jelene +jeleniew +jelinek +jelske +jem +jemczyk +jemie +jemima +jemimah +jemmie +jemmy +jen +jen-chen +jen-hua +jena +jenchen +jenda +jenelle +jeng +jenhua +jeni +jenica +jeniece +jenifer +jeniffer +jenilee +jenine +jenkins +jenkinso +jenn +jenna +jennee +jenner +jenness +jennette +jenni +jennica +jennie +jennifer +jennilee +jennine +jennings +jenny +jeno +jens +jensen +jensenwo +jenson +jeong +jephthah +jepson +jer-huan +jerad +jerald +jeralee +jeramey +jeramie +jere +jereme +jeremiah +jeremias +jeremie +jeremy +jerhuang +jeri +jermain +jermaine +jermayne +jernigan +jeroen +jerome +jeromy +jeronimo +jeroski +jerreld +jerri +jerrie +jerrilee +jerrilyn +jerrine +jerrold +jerrome +jerry +jerrylee +jervis +jerzy +jeska +jesper +jess +jessa +jessalin +jessalyn +jessamin +jessamyn +jesse +jessee +jesselyn +jessey +jesshope +jessi +jessica +jessie +jessika +jessup +jessy +jester +jesty +jesus +jet +jeter +jeth +jethro +jeurene +jew +jewel +jewell +jewelle +jewels +jewett +jey +jeyarara +jezioran +jhingran +ji +ji-chuu +jia +jia-wen +jiak-kwa +jiakkwan +jian +jianchen +jiang +jianli +jiann +jiann-ya +jiannyan +jianou +jianqi +jianxing +jianye +jianyun +jiawen +jiayi +jiayuan +jichuu +jie-yong +jiejie +jiethye +jieyong +jihad +jihan +jihyun +jiin-shu +jiinshuh +jilann +jilisa +jill +jillana +jillane +jillayne +jilleen +jillene +jilli +jillian +jillie +jilly +jim +jimenez +jiming +jiminy +jimmie +jimmy +jims +jimson +jin +jin-ho +jin-nan +jin-song +jin-yuan +jin-yun +jinann +jinchao +jindal +jing +jing-ru +jingbai +jinglun +jingru +jinho +jinhua +jinlun +jinn-kue +jinnan +jinnkuen +jinny +jinsheng +jinsong +jinsoo +jinyuan +jiri +jirina +jiro +jisang +jisheng +jitendra +jiuhuai +jivan +jiyuan +jiyue +jo +jo ann +jo-ann +jo-anne +jo-marie +joachim +joachimp +joan +joana +joane +joanie +joann +joanna +joannah +joanne +joannes +joannidi +joannie +joannis +joao +joaquin +job +jobe +jobey +jobi +jobie +jobina +joby +jobye +jobyna +jocelin +joceline +jocelyn +jocelyne +jochem +jochen +jock +jocko +jodee +jodi +jodie +jodine +jodoin +jodoin-s +jody +joe +joeann +joel +joela +joelie +joell +joella +joelle +joellen +joelly +joellyn +joelynn +joeph +joerg +joeri +joete +joey +joffe +johan +johan am +johan ch +johan se +johanama +johanchr +johann +johanna +johannah +johanne +johannes +johannse +johanseb +johansen +johanson +johathan +john +john-jr +john-pau +john-sr +johna +johnath +johnatha +johnatho +johnette +johni +johnna +johnni +johnnie +johnny +johns +johnsen +johnson +johnsson +johnston +johny +joice +joiner +joji +jojo +joke +jolanda +jole +jolee +joleen +jolene +joletta +joli +jolicoeu +jolie +jolin +joline +jolitz +joll +jolliffe +joly +jolyn +jolynn +jon +jonah +jonas +jonathan +jonathon +jonczak +jone +jonell +jonelle +jones +jong +jong-chi +jong-woe +jongchih +jonghun +jonghyuk +jongsun +jongwoei +jongwoo +joni +jonie +jonis +jonkheer +jonson +jonthan +joo +joo-euin +joo-geok +joon +joong +jooran +jooyul +joplin +jordain +jordan +jordana +jordanna +jordi +jordon +jorey +jorgan +jorge +jorgense +jori +jorie +joron +jorrie +jorry +jory +jos +josanne +joscelin +jose +josee +josef +josefa +josefina +joseito +joselito +joseph +josepha +josephin +josephs +josey +josh +joshi +joshia +joshua +joshuah +josi +josiah +josiane +josias +josie +josine +josip +joslin +joson +josselyn +jossine +josy +joubert +joudrey +jourdain +jourdan +jovo +jowett +joy +joya +joyan +joyann +joyce +joycelin +joydeep +joye +joyner +joyous +jozef +jozsef +jr +jsandye +jsbach +juan +juana +juanita +juarez +jubainvi +jubb +jubenvil +jubinvil +juby +jud +judah +judas +judd +jude +judge +judi +judie +judith +juditha +judithre +judon +judy +judye +judyresn +juergen +juers +jugandi +juh-shiu +juha +juhan +juhshiun +jui +jui-fen +juieta +juifen +juile +jukka +julayne +jule +julee +jules +juli +julia +julian +juliana +juliane +juliann +julianna +julianne +julianto +julie +julieann +julien +julienne +juliet +julieta +julietta +juliette +julina +juline +julio +julissa +julita +julius +jun +jun-li +junaid +june +juneau +juneho +junette +jung +jung-hua +junghua +jungmeis +juni +junia +junie +junina +junk +junkie +junkin +junli +junmeng +junzo +juozas +jurafsky +jurek +jurevis +jurewicz +jurg +jurgen +jurgens +jurgutis +juri +jurman +juscesak +juskevic +jussi +justen +justin +justina +justine +justinia +justinn +justino +justis +justo +justus +jusuf +jutta +jwahar +jyh-dong +jyh-doug +jyhdong +jyhdoug +jyoti +jyun-che +jyunchen +jyuo +kaare +kabe +kabel +kaboliza +kabuli +kac +kacey +kacie +kacor +kacsor +kacy +kaczmare +kaczmars +kaczynsk +kadah +kadamani +kaden +kadiyala +kadlecik +kaefer +kaehler +kaela +kaete +kagan +kah-ming +kahaleel +kahan +kahhale +kahhan +kahil +kahkonen +kahlil +kahn +kahneman +kahnert +kahtasia +kai +kai-bor +kai-ming +kai-wah +kai-wai +kaia +kaibor +kaiching +kaid +kaidanne +kaigler +kaila +kaile +kaileen +kailey +kain +kaine +kaiser +kaitlin +kaitlyn +kaitlynn +kaiwah +kaiwen +kaj +kaja +kajeejit +kaji +kakalina +kaki +kakou +kaksonen +kakuta +kala +kalab +kalai +kalaiche +kalair +kalappa +kalash +kale +kaleb +kalechst +kaleena +kales +kaley +kali +kalie +kalil +kalila +kalina +kalinda +kalindi +kaliski +kalitzku +kalle +kallewar +kalli +kallio +kally +kalman +kalnitsk +kalogera +kalpak +kalpit +kalra +kalsey +kalt +kalugdan +kaluzny +kalvin +kalwa +kalwarow +kalyan +kalyani +kalyn +kam +kam-hung +kam-suen +kamal +kaman +kamas +kambhamp +kambiz +kamboh +kameko +kamel +kamerson +kamhung +kamie +kamil +kamila +kamilah +kamillah +kaminsky +kamiya +kamiyama +kamlesh +kammerer +kamminga +kamol +kamoun +kamran +kamyar +kamyszek +kan +kan-hung +kana +kanagend +kanani +kanata +kanchit +kandace +kandappa +kandi +kandra +kandy +kane +kaneko +kaneshir +kang +kang-gil +kang-nin +kang-yua +kangelis +kangkun +kangning +kanhung +kania +kanies +kanika +kannan +kannel +kanneman +kanno +kansara +kant +kanthan +kantor +kanu +kanungo +kanwalji +kanwar +kanya +kao +kaoru +kaoud +kapadia +kapatou +kapella +kapil +kaplan +kapp +kappos +kaps +kapsa +kapsch +kapuscin +kara +kara-lyn +karaali +karademi +karalee +karalynn +karam +karan +karass +karattup +karchevs +kardomat +kardos +kare +karee +kareem +karel +karels +karen +karena +karhunie +kari +karia +kariann +karibian +karie +karil +karilynn +karim +karin +karina +karine +kariotta +karisa +karissa +karita +karkotsk +karl +karla +karlan +karlee +karleen +karlen +karlene +karlens +karlette +karlie +karlik +karlis +karloff +karlon +karlotta +karlotte +karlson +karly +karlyn +karmali +karmen +karmous- +karn +karna +karnazes +karney +karol +karola +karole +karolefs +karolien +karolina +karoline +karoly +karon +karp +karr +karrah +karrie +karry +karsan +karsner +karsz +kartik +kartikey +karunara +karwowsk +kary +karyl +karylin +karyn +kas +kasbia +kasbow +kasdorf +kasey +kashani- +kashef +kashima +kashul +kasifa +kaspar +kasparia +kasper +kasprzak +kass +kassam +kassandr +kassem +kassey +kassi +kassia +kassie +kassissi +kast +kastelbe +kasten +kastner +kaston +kasumovi +kat +kata +katalin +katarina +kataryna +katcher +katchmar +kate +katee +katerina +katerine +katey +kath +katha +katharin +katharyn +kathe +katherin +katheryn +kathi +kathie +kathleen +kathlin +kathnels +kathreri +kathrine +kathryn +kathryne +kathy +kathyb +kathye +kati +katibian +katie +katina +katine +katinka +katja +katleen +katlin +kato +katrin +katrina +katrine +katrinka +katsoura +katsumi +katsunor +katti +kattie +katuscha +katusha +katy +katya +katz +katzenel +kaudel +kauffeld +kauffman +kaufman +kaufmann +kaunas +kaura +kaus +kausche +kaushik +kavaler +kavanagh +kavid +kavis +kawa +kawabata +kawaguch +kawahara +kawakami +kawamura +kawashim +kawauchi +kay +kaya +kayaliog +kayar +kaycee +kaye +kayla +kayle +kaylee +kayley +kaylil +kaylyn +kayne +kaypour +kayser +kayvan +kaz +kazem +kazimier +kazmierc +kazue +kazuhiko +kazuhiro +kazuhito +kazuko +kazunori +kazuo +kazuyuki +kea +keala +kealey +kean +keane +kearney +kearns +keary +keast +keates +keating +keaton +keats +kebede +kechichi +keck +kedah +kedron +kee +keef +keefe +keefer +keegstra +keehan +keehn +keelan +keelby +keeler +keeley +keelia +keels +keely +keen +keenan +keene +keene-mo +keep +keer +kees +keever +keffer +kehler +kehoe +kehr +kei +keifer +keighley +keilholz +keilty +keim +kein +keinosuk +keir +keiser +keish +keisuke +keitel +keith +kejing +kelbe +kelbee +kelby +kelcey +kelci +kelcie +kelcy +keldon +kele +kelemen +kelessog +kelila +kelin +kelkar +kell +kelland +kellby +kelleher +kellen +keller +kellerma +kellett +kelley +kelleye +kelli +kellia +kellie +kellina +kellogg +kellsie +kellum +kelly +kelly-er +kellyann +kellyeri +kelner +kelsay +kelsch +kelsey +kelsi +kelso +kelsy +keltouma +kelvin +kelwin +kely +kem +kemal +kember +kemish +kemkeng +kemme +kemp +kempf +kempffer +kempler +kemppain +kempski +kempster +ken +kenda +kendal +kendall +kendel +kendell +kendi +kendra +kendre +kendrick +kenedi +kenik +kenji +kenkel +kenlan +kenmir +kenn +kenna +kennaday +kennan +kennard +kennedy +kenneth +kennett +kenney +kennie +kennith +kennon +kenny +kenol +kenon +kensinge +kent +kenta +kentaro +kenton +kenworth +kenyon +keogh +kepekci +kepler +ker +kera +kerby +kerensa +kerfoot +keri +keriakos +keriann +kerianne +kerith +kerk +kerley +kerlovic +kermie +kermit +kermy +kernahan +kernan +kerner +kernodle +kerns +kerr +kerri +kerri-an +kerrie +kerrill +kerrin +kerry +kerschen +kerschne +kerstin +kerwin +kerwinn +keseris +keshab +keshav +kesler +kesley +keslie +kesling +kessel +kessia +kessiah +kessing +kessler +kessley +kestelma +kester +kestutis +ketan +ketao +ketcham +ketcheso +ketchum +ketkar +ketley +ketsler +ketterer +ketti +kettie +kettles +ketty +keung +keuning +kev +kevan +keven +keveny +kevin +kevina +kevon +kevyn +keyes +keynes +keys +keyvan +khac +khachatr +khadbai +khai +khalaf +khaled +khalid +khalil +khalilza +khamdy +khanh +khanna +khanvali +khariton +khatib +khatod +khatri +khawar +khedkar +khesin +khezri +khieu +khim +khimasia +kho +khoa +khodosh +khoinguy +khon +khorami +khorrama +khosla +khosravi +khosro +khosrow +khouderc +khoury +khouzam +khue +khueh-ho +khuehhoc +khuon +khurana +khurshid +khyra +ki +kiah +kial +kiala +kiam +kian +kiang +kibler +kidd +kiebel +kiecksee +kiefer +kiel +kiele +kielstra +kiely +kiem +kien +kien-ngh +kienan +kiennghi +kiens +kieran +kiernan +kieron +kiersten +kiet +kieunga +kiger +kigyos +kihyen +kijin +kikelia +kiki +kikki +kiko +kikuchi +kikuta +kilbank +kilburn +kilby +kilcoin +kilcoyne +kile +kiley +kilgore +kilian +killam +killeen +killen +killer +killian +killie +killy +kilner +kilpatri +kilsaas +kilzer +kim +kim-elee +kim-minh +kim-stac +kim-tram +kimball +kimbarov +kimbell +kimberle +kimberli +kimberly +kimble +kimbo +kimbra +kimbrell +kimbroug +kimeleen +kimi +kimihiko +kimiko +kimio +kimler +kimm +kimma +kimme +kimmett +kimmi +kimmie +kimmo +kimmy +kimoto +kimstace +kimura +kin +kin-wai +kin-yee +kinahan +kinamon +kincaid +kinch +kindel +kindem +kindra +king +king-hau +kingaby +kingan +kingdon +kingrey +kingsbur +kingshot +kingslan +kingsley +kingsly +kingston +kingzett +kinh +kinley +kinman +kinna +kinnaird +kinney +kinnibur +kinnie +kinny +kinos +kinoshit +kinrys +kinsella +kinsey +kinsley +kinsman +kinson +kinstley +kinstry +kip +kipling +kipnis +kipp +kippar +kipper +kippie +kippy +kira +kirady +kirbee +kirbie +kirby +kirchner +kirchoff +kirfman +kiri +kirit +kirk +kirkby +kirkenda +kirkham +kirkland +kirkley +kirkpatr +kirkwood +kirley +kirn +kirouac +kirsi +kirsten +kirsteni +kirsti +kirstie +kirstin +kirstyn +kirt +kirtikum +kish +kishi +kishor +kishore +kissee +kissiah +kissie +kistner +kit +kita +kitajima +kitrick +kitson +kitt +kitti +kittie +kittinge +kitty +kitzmill +kivell +kiwon +kiyohara +kiyoharu +kiyoon +kizzee +kizzie +kjeld +kjell +klaas +klaassen +klammer +klamner +klapper +klapphol +klara +klarika +klarrisa +klashins +klasky +klassen +klatchko +klaudia +klaudiny +klaudt +klaus +klavkaln +klazien +klazina +klebsch +klein +klemens +klement +kleon +klepping +kletchko +klett +kleynenb +klier +klimas +kliment +klimon +kline +kling +klingspo +klink +klod +klodt +klosterm +kloth +klotz +klowak +klug +kluger +kluke +klutts +kmem +knapp +knappe +knapper +knapton +knecht +kneedler +kneese +kneeshaw +kneisel +knes-max +kness +knickerb +knieps +knighten +knighton +knio +knipe +knitl +knittel +knobeloc +knobloch +knorp +knorr +knouse +knowles +knox +knudsen +knut +knute +ko +ko-yang +koa +koang +koay +kobayash +kobeski +kobiersk +koblitz +kobreek +koch +kochansk +kochis +kodmur +kodnar +kodsi +kody +koelbl +koeller +koellner +koeman +koen +koenraad +koens +koerner +kogan +kogelnik +kohalmi +kohalmi- +kohl +kohler +kohm +kohn +kohnhors +kohut +koichi +koiste +koji +kok +kok-khia +kokkat +koko +kokoska +kokosopo +kolahi +kolappa +kolavenn +kolb +kolbe +koldinge +kolek +kolenda +kolesnik +koleyni +kolk +kolkka +kollen +koller +kollman +kollmorg +kolodiej +kolodzie +kolos +kolovson +kolski +kolton +koman +komaromi +komatsu +komenda +komorows +konarski +konda +kondagun +konforti +kong +kong-que +koning +konno +konomis +konrad +konradi +konstan +konstanc +konstant +konstanz +koo +koohgoli +koohi +koolstra +koolwine +koonce +koontz +kooyoung +kopala +kopell +kopfman +kopke +koprulu +kora +koral +koralle +koran @ +koray +korbe +korbel +korda +kordik +kordon +kordula +kore +korea +korean +korek +korella +koren +koressa +korest +korey +korf +kori +korie +korn +kornachu +kornegay +korney +kornitze +korpela +korrie +korry +kort +kortekaa +kortje +kory +kos +kosarski +kosasih +kosiorsk +kositpai +koskie +koskinen +koslowsk +kosman +kosnaski +kosowan +koss +kostas +kosten +koster +kostowsk +kosturik +kostyniu +kot +kotamart +kotaro +kotler +kotval +kotyk +kou +kou-yuan +kouba +kouhi +kouichir +kouidis +kouji +kouyuan +kovac +kovacs +koval +kovarik +kovats +koverzin +kowal +kowalcze +kowalesk +kowalkow +kowallec +kowalski +kowalsky +koyang +kozak +kozelj +koziol +kozlowsk +kozsukan +kozuch +kozyra +kpodzo +krabicka +kraehenb +krajacic +krajesky +krakowet +kramar +kramer +kranenbu +krater +kratz +krauel +kraus +krausbar +krause +krautle +krawchuk +krawec +kreiger +kreimer +krenn +krenos +kresl +kretsch +krick +kridle +krieg +kriegler +krienke +krier +kriko +krikoria +krinda +kring +kris +krisa +krisha +krishan +krishin +krishink +krishna +krishnah +krishnam +krishnan +krishnas +krispin +krissie +krissy +krista +kristal +kristan +kriste +kristel +kristen +kristi +kristian +kristie +kristien +kristin +kristina +kristine +kristjan +kristo +kristofe +kristoff +kristofo +kriston +kristoph +kristos +kristy +kristyn +kriton +krivossi +kriz +krodel +kroeger +krogh +krol +kroman +kromer +krone +krotish +krousgri +krowlek +krozser +krten +krueger +krug +kruger +krull +krummell +krumwied +kruse +kruuseme +kruziak +krym +krysia +kryski +krysko +krysta +krystal +krystall +krystle +krystn +krystyna +krzyszto +ktusn +ku +kuan +kuang +kuang-ts +kubash +kube +kubik +kubitsch +kuchelme +kuchinsk +kuchta +kucirek +kuczynsk +kudas +kudrewat +kue +kuechler +kuehn +kuehne +kuhfus +kuhlkamp +kuhn +kuhns +kui +kui-soon +kuivinen +kujanpaa +kulachan +kuldip +kulhy +kulik +kulikows +kulinski +kulkarni +kulman +kum +kum-meng +kumagai +kumamoto +kumar +kumares +kumi +kummer +kun +kun-ming +kundanma +kundel +kundert +kunecke +kung +kung-chi +kungchia +kunie +kunihiko +kunitaka +kuniyasu +kunjal +kunkel +kunming +kuntova +kunz +kunze +kuo +kuo-chua +kuo-feng +kuo-lian +kuochuan +kuofeng +kuoliang +kupe +kupfer +kupferma +kupfersc +kupidy +kupitz +kuracina +kurash +kurauchi +kurczak +kurdas +kurdziel +kure +kureshy +kurian +kurio +kurita +kurniawa +kurolapn +kurose +kurowski +kursell +kurt +kurth +kurtis +kurtz +kuruppil +kuryliak +kurylyk +kus +kusan +kushan +kushner +kushnir +kushwaha +kushwind +kusmider +kusum +kusumaka +kuswara +kusyk +kutac +kutch +kutger +kutschke +kutten +kuykenda +kuzbary +kuzemka +kuzyk +kuzz +kvochak +kwa +kwak +kwan +kwang +kwang-ch +kwang-lu +kwangchi +kwangchu +kwanglu +kwangsoo +kwant +kwast +kwee +kwei +kwei-san +kwiatkow +kwing +kwissa +kwock +kwok +kwok-cho +kwok-kin +kwok-lan +kwok-san +kwok-wa +kwokchoi +kwokkin +kwoksang +kwong +ky +kydd +kye-hong +kyehong +kyeong +kyla +kyle +kylen +kyler +kylie +kylila +kylo +kylynn +kym +kynthia +kyoko +kyomun +kyoon +kyoung +kyrie +kyrstin +kyu +kyu-sung +kyung +kyungchu +kyungyoo +kyusung +kyzer +l'anglai +l'ecuyer +l'heureu +l;urette +la +la verne +laale +lab +laba +labarge +labauve +labelle +labenek +laberge +labfive +labiche +labonte +labossie +labrador +labranch +labrie +labrinos +labuhn +lac +lacasse +lacee +lacelle +lacey +lachambr +lachance +lachine +lachowsk +lachu +lacie +lackenba +lackie +lackmann +lacombe +lacosse +lacoste +lacroix +lacy +ladan +ladasky +ladd +ladean +ladell +ladet +ladonna +ladouceu +ladva +ladymon +laetitia +lafarge +lafargue +lafata +lafayett +laferrie +lafever +lafferty +laflamme +lafleur +lafontai +laforge +laframbo +lafrance +lagace +lagache +lagarde +lagrande +lagrange +lahaie +lahaye +lahey +lahlum +lahteenm +lai +laidlaw +laila +laina +laine +lainesse +lainey +laing +laird +laitinen +lajzerow +laker +lakhani +lakhian +lakier +lakins +lakoff +lakshan +lakshmi +lakshmin +lalani +lali +lalibert +lalit +lalitha +lalka +lally +lalo +lalonde +lalu +lamar +lamarche +lamarque +lamarre +lambert +lambregt +lamedica +lamers +lamey +lamia +lamirand +lamm +lammond +lamond +lamonde +lamont +lamontag +lamoreux +lamothe +lamotte +lamouche +lamoureu +lampe +lampert +lamphier +lampman +lamport +lamy +lan +lana +lanae +lanava +lancaste +lance +lancelot +lanctot +land +landaver +landay +lande +lander +landers +landis +landman +landon +landriau +landry +lane +lanette +laney +lang +langdon +lange +langelie +langenbe +langer +langett +langevin +langford +langlais +langley +langlois +langner +langsdon +langstaf +langston +langton +lani +lanie +laniel +lanier +lanita +lankford +lanna +lannan +lanni +lannie +lanny +lanoe +lanoszka +lanoue +lanouett +lans +lansuppo +lanteign +lanthier +lantos +lantto +lantz +lanunix +lanwan +lanz +lanzkron +lao +lap +lapchak +lapierre +lapkin +laplace +laplante +lapointe +laporte +lapostol +lappan +laprade +lapre +laprise +lapsley +laquinta +lar +lara +larabie +laraia +laraine +larche +lareine +larese +lari +larimer +larin +larina +larine +larisa +larissa +larivier +lark +larkin +larkins +larmour +larn +larner +laroche +larock +larocque +larose +larribea +larrigan +larry +lars +larsen +larson +larstone +larue +laruffa +larus +larwill +lary +larysa +laryssa +las +lasch +laschuk +laser +laserjet +lash +lashansk +lasher +lashmit +lashonda +lask +laskaris +laskin +laslo +lasserre +lassig +lassiter +lasson +lassonde +laster +latashia +latchfor +latella +laten +latessa +latha +lathangu +lathrop +latia +latif +latin +latisha +latonia +latonya +latorre +latour +latreill +latrena +latrina +lattanzi +latulipp +lau +laubenhe +lauderda +laufer +laugher +laughlin +laughrid +laughton +launce +laura +lauraine +laural +lauralee +lauramae +laure +laureano +lauree +laureen +laurel +laurella +lauren +laurena +laurence +laurene +laurens +laurent +lauretta +laurette +lauri +lauria +lauriann +lauriaul +laurice +laurich +laurie +laurier +laurilyn +laurin +laurine +lauris +lauristo +lauritz +laurna +lauro +laursen +lauruhn +laury +lauryn +lauson +lauten +lauther +laux +lauze +lauzon +lavagno +lavallee +lavar +lavarnwa +lavecchi +laveda +lavelle +lavena +lavergne +laverna +laverne +lavers +laverty +lavictoi +lavictor +lavigne +laville +lavina +lavinia +lavinie +laviolet +lavoie +lavonda +lavorata +law +lawbaugh +lawler +lawless +lawlis +lawlor +lawrence +lawrie +lawry +laws +lawson +lawther +lawton +lay +layanand +layer +layla +layne +layney +layton +laz +lazar +lazare +lazaro +lazarou +lazarowi +lazarus +lazer +lazlo +lazure +lazzara +lcarrol +le +lea +leader +leads +leafloor +leah +leahy +leander +leandra +leang +leann +leanna +leanne +leanor +leanora +leaphear +leary +leatham +leathers +leaton +leatrice +leavell +leaver +leavitt +lebaron +lebars +lebbie +lebeau +lebel +leblanc +leblond +lebo +lebon +lecandro +lecien +leckie +leclair +leclaire +leclerc +lecompte +lecours +lecouteu +leda +ledamun +leddy +leder +lederman +ledet +ledford +ledinh +ledou +ledoux +ledu +leduc +ledwell +lee +lee-anne +leeann +leeanne +leecia +leela +leelah +leeland +leena +leendert +leenher +leesa +leese +leeson +leeuwen +lefebvre +lefevre +leffler +leftwich +lefty +legeny +leger +legg +leggett +legolas +legra +legrandv +legris +legros +legrove +legrow +legs +leguen +lehar +lehman +lehmann +lehrbaum +lehtinen +lehtovaa +lei-see +leia +leiba +leibich +leibovit +leibowit +leiceste +leicht +leidenfr +leiding +leif +leigh +leigha +leighann +leighton +leiker +leil +leila +leilah +leima +lein +leinen +leisa +leisha +leistico +leita +leitch +leite +leith +leitman +leitner +leitrick +leiwe +lek +lela +lelah +leland +lelia +lem +lemaire +lemar +lemay +lemieux +lemky +lemley +lemmie +lemmy +lemuel +lemyre +len +lena +lenard +lenathen +lenci +lendon +lenee +lenee' +lenehan +lenelle +lenette +leney +lengel +lenhard +leni +lenin +lenior +lenir +lenka +lenna +lennard +lennart +lennie +lennig +lenny +leno +lenora +lenore +lenox +lentz +leny +lenz +lenzi +leo +leo-miza +leocadio +leodora +leoine +leola +leoline +leon +leona +leonanie +leonard +leonardo +leonas +leone +leonelle +leonerd +leong +leonhard +leonid +leonida +leonidas +leonie +leonor +leonora +leonore +leontine +leontyne +leopold +leora +leoutsar +leow +lepage +lepine +lepore +leppert +lepreau +lerch +leres +leroi +leroux +leroy +les +lesa +lesco +lescot +leshia +leshowit +lesia +lesley +lesli +leslie +lesly +lesmeris +lesniak +lesourd +lesperan +lessard +lessin +lester +lesway +lesya +leta +letarte +letchwor +letendre +leth +letha +lethbrid +lethebin +lethia +leticia +letisha +letitia +letizia +letourne +letsome +lett +letta +lettang +letti +lettie +letty +letulle +leuenber +leung +leupold +leuty +lev +levac +levasseu +leveille +levent +levere +levert +levesque +levey +levi +levin +levine +levis +levisky +levitin +levo +levon +levy +lew +lewandow +lewek +lewellen +lewes +lewie +lewinski +lewis +lewiss +lewright +lex +lexi +lexie +lexine +lexis +lexy +ley +leyden +leydig +leyla +leyton +lezlee +lezley +lezlie +lheureux +li +li-ming +li-xi +lia +liad +lial +liam +lian +lian-hon +liana +liane +liang +liangchi +lianna +lianne +liao +lias +liason +liaurel +liaw +lib +libadmin +libbey +libbi +libbie +libby +liber +libor +libov +libraria +licandro +licata +lication +licerio +licha +licht +lichtenb +lichtens +lichum +lichun +licia +lida +liddell +liddle +lidia +lidio +lidster +lidstone +liduine +lieberma +liebrech +liedl +lief +liem +lien +lieneman +lienhard +liepa +liesa +liesbeth +liese +liesel +liesemer +liesenbe +liesie +liesl +lieure +lievaart +liew +lifshey +lightfie +lightfoo +lighthal +lighthis +lightowl +ligon +ligurs +lijphart +likert +likourgi +lil +lila +lilah +lilas +lili +lilia +lilian +liliana +liliane +lilias +lilin +lilith +lilla +lillenii +lilli +lillian +lillie +lillien +lillis +lilllie +lilly +lily +lilyan +limeina +limerick +limin +liming +lin +lin-chan +lin-e +lin-ni +lina +linas +linaugh +linback +linberg +linc +linchang +lincoln +lind +linda +linda-jo +lindamoo +lindberg +lindell +lindemul +linden +lindenla +linder +lindholm +lindi +lindie +lindler +lindon +lindow +lindquis +lindsay +lindsey +lindstro +lindsy +lindt +lindy +line +linea +linebarg +linegar +lineham +linell +linet +lineth +linette +linfield +ling +ling-hui +ling-yu +ling-yue +ling-zho +lingafel +linghui +lingyan +lingyu +linh +link +linke +linklett +linley +linn +linnea +linnell +linnet +linni +linnie +lino +linoel +linsley +linton +linus +linwood +linzie +linzy +lion +lionel +lionello +liou +lipari +lipe +liping +lippens +lippman +lipschut +lipscomb +lipski +lipton +lira +lisa +lisabeth +lisah +lisak +lisandro +lisbeth +lisch +lischyns +lise +lisee +lisenchu +lisetta +lisette +lish +lisha +lishe +liskoff +lisle +liss-mon +lissa +lissi +lissie +lissmoni +lissy +list +lister +liston +listonic +lita +litherla +litt +littau +littlewo +litva +litz +litzenbe +liu +liuka +liv +liva +livas +livek +livengoo +liverman +livermor +lives +livezey +livia +livingst +livinsto +livio +liviu +livnah +livshits +livvie +livvy +livvyy +livy +liwana +liwen +lixi +liyuan +liyun +liz +liza +lizabeth +lizak +lizbeth +lizette +lizz +lizzi +lizzie +lizzy +ljiljana +ljilyana +ljubicic +llacuna +llaguno +llanos +llewelly +llopart +lloyd +llywelly +lo +loa +loadbuil +loader +loadsum +loan +loarie +loay +lobasso +lobaugh +lobello +lober +lobianco +lobin +lobue +loc +locicero +lock +lockard +locke +locken +lockett +lockhart +lockwood +lococo +lodesert +lodovico +loe +loeffler +loeiz +loel +loella +loes +loesje +loewen +loftis +logan +logaraja +loggins +loghry +logntp +logue +loh +lohoar +loi +loire +lois +loise +loiseau +loisel +lojewski +loke +loker +lola +loleta +lolita +lollis +lolly +lombard +lombardo +lombardy +lombrink +lommen +lon +lon-chan +lona +lonald +lonchan +londhe +london +lonee +lonergan +long +long-chu +longbott +longcham +longchun +longdist +longfiel +longhenr +longo +longpre +longsong +longtin +lonhyn +loni +lonidas +lonn +lonna +lonnard +lonneke +lonni +lonnie +lonnman +lonny +lonsdale +loo +loon +loong +loos +looyen +lope +loper +loperena +lopes +lopez +lopiano +lopinski +loponen +loquerci +lora +lora-lee +lorain +loraine +loralee +loralie +loralyn +lorance +lorant +lorcan +lord +loree +loreen +lorelei +lorelle +lorelynn +loren +lorena +lorenc +lorene +lorens +lorenz +lorenza +lorenzen +lorenzo +loreta +loretta +lorettal +lorette +lorfano +lori +loria +lorianna +lorianne +lorie +lorien +lorilee +lorilyn +lorimer +lorin +lorincz +lorinda +lorine +loring +lorint +loris +lorita +lorletha +lorli +lormor +lorna +lorne +loro +lorrain +lorraine +lorrayne +lorrel +lorri +lorrie +lorrin +lorrine +lorry +lortie +lory +loryn +los +losfeld +losier +loso +losses +lotan +lote +lotfalia +lotfi +lothaire +lothar +lothario +lotochin +lott +lotta +lotte +lotti +lottie +lotty +lotz +lou +lou-hell +louann +loucel +loudiadi +louella +lough +loughery +loughran +loughrin +louhelle +louie +louiqa +louis +louis-ph +louis-re +louisa +louise +louisett +louissei +louk +louka +loukas +louladak +lourdes +loutitia +loux +lovas +lovatt +loveday +lovegrov +lovejoy +lovekin +lovelace +loveland +loveless +lovell +lovett +lovin +lovina +lovitt +lowder +lowe +lowell +lowery +lowietje +lowman +lowrance +lowrie +lowry +lowther +loxton +loy +loyd +loydie +loyer +loyola +loyst +loza +lozier +lozinski +lpo +lrc +lrcrich +lrcrtp +lsi +lsiunix +lu +luan +luann +luanne +lubliner +lubna +lubomir +lubomyr +luc +luca +lucais +lucas +lucco +luce +lucente +lucey +lucho +luci +lucia +lucian +luciana +luciani +luciano +lucias +lucie +lucien +lucienne +lucifer +lucila +lucile +lucilia +lucille +lucina +lucinda +lucine +lucio +lucita +lucius +lucking +lucky +lucretia +lucy +ludchen +ludovico +ludovika +ludvig +ludvikse +ludwick +ludwig +lue +luella +luelle +luen +luetchfo +luetke +luettcha +luff +lugsdin +lugwig +luh-maan +luhcs +luhmaan +lui +luigi +luin +luis +luisa +luise +luiza +lujanka +luk +luk-shun +lukas +lukassen +lukaszew +luke +luker +lukers +lukic +lukie +lukshis +lukshun +lula +lulita +lulu +lum +lum-wah +lumley +lumsden +lun +luna +lund +lunde +lundhild +lundstro +lundy +lunk +lunn +luoedora +luoma +luong +lupatin +lupher +lupien +luping +luquire +lura +lurette +luria +lurleen +lurlene +lurline +lusa +luscombe +lussier +luszczek +lutan +lutero +luther +luthin +lutz +luu +luuk +luwemba +luxford +luying +luyten +luz +luzarrag +luzine +ly +ly-khanh +lyall +lychak +lyda +lydda-ju +lydia +lydie +lydon +lyell +lyle +lyliston +lyman +lyn +lynda +lyndal +lynde +lyndel +lyndell +lyndia +lyndon +lyndsay +lyndsey +lyndsie +lyndy +lyne +lynea +lynelle +lynett +lynetta +lynette +lynham +lynn +lynna +lynne +lynnea +lynnell +lynnelle +lynnet +lynnett +lynnette +lynsey +lynton +lynwood +lyon +lyons +lyse +lysinger +lyssa +lystad +lystiuk +lystuik +lysy +lytle +lzrd +maahs +maaike +maala +maan +maarten +maas +mab +mabel +mabelle +mabes +mable +mabson +mabuchi +mac +mac maid +macadams +macalik +macarthu +macaulay +macbride +maccallu +maccarth +macchius +maccombi +macconai +maccorma +macderma +macdonal +macdonel +macdouga +macdowal +macduff +mace +macelwee +macfarla +macgilli +macgrego +mach +machan +machatti +machika +machnick +maciej +maciejew +maciel +macinnes +macinnis +macisaac +maciver +mack +mackay +mackel +mackenzi +mackey +mackin +mackinno +macklem +macklin +mackzum +maclaren +maclauri +maclean +maclella +maclenna +macleod +macmarti +macmeeki +macmilla +macmulli +macnaugh +macneil +macneill +macnicol +macoosh +macphail +macphers +macpost +macquist +macrae +macreyno +macsuppo +mada +madalena +madalene +madalyn +madan +madani +maddalen +maddi +maddie +maddix +maddox +maddy +madel +madelain +madelein +madelena +madelene +madelin +madelina +madeline +madella +madelle +madelon +madelyn +mader +madge +madgett +madhav +madhavan +madhu +madhukar +madigan +madill +madisett +madison +madl +madlen +madlin +mado +madonna +madras +madsen +maduri +mady +mae +maegan +maennlin +maenpaa +maeya +mag +magbee +magda +magdaia +magdale +magdalen +magdi +magdiel +magdy +mage +maged +magee +mages +maggee +maggi +maggie +maggy +maghsood +maginley +maglione +magnan +magnolia +magnum +magnuson +magnusse +magnusso +magrath +magri +mags +maguire +mah +mahaffee +mahala +mahalia +mahaling +mahbeer +mahboob +mahendra +maher +mahesh +maheu +maheux +mahfouz +mahibur +mahiger +mahin +mahlen +mahlig +mahlon +mahmood +mahmoud +mahmud +mahmut +mahn +mahon +mahonen +mahoney +mahoney- +mahshad +mai +maia +maible +maid +maidenhe +maidens +maidisn +maidlab +maidsir +maidxpm +maier +maiga +maighdil +maijala +maika +maikawa +maikhanh +mail +mailroom +mainardi +maine +mainoo +maint +mainvill +mainwari +mair +maire +maisey +maisie +maison +maisonne +maitilde +maitland +maitreya +majd +maje +majeed +majek +majella +majernik +majid +majmudar +major +majors +majumdar +majury +mak +makam +makarand +makarenk +makary +makeda +makiko +makino +makinson +makohoni +makoid +makoto +maksoud +maksuta +mal +mala +malachi +malaclyp +malaher +malaika +malaivon +malam +malani +malanie +malanos +malavia +malaysia +malchy +malcolm +malden +malec +malee +malek +malena +maleski +malethia +malgorza +malgosia +malhi +malhotra +malia +maliepaa +malik +malina +malinda +malinde +malisic +maliski +malissa +malissia +malizia +malkani +malkiewi +malkinso +mallari +malle +mallejac +maller +mallett +mallik +mallissa +mallorie +mallory +malloy +mallozzi +malmqvis +malone +maloney +malorie +malory +malott +malta +maltby +maltese +maludzin +malus +malva +malvin +malvina +malynda +malynne +malynows +malyszka +malzahn +mame +mami +mamie +mamikuni +mamoru +mamoulid +man +man-fai +manageme +manager +manahil +manalac +manami +manas +manavend +manceau +manchest +mancini +mand +manda +mandana +mandayam +mandel +mandevil +mandi +mandie +mandrusi +mandruso +mandy +maneatis +manek +maness +manette +manfred +manfredo +mang +mangione +mangum +manh +manhatte +mani +manica +manickam +manijeh +manilal +manimozh +maninder +manish +manitius +manitsas +manjari +manjeri +manjinde +manjit +manjrike +mankowsk +manley +manli +manly +manmohan +mann +manna +manner +manners +mannie +manning +mannino +mannion +manno +manny +mano +manoel +manoharm +manoj +manol +manolios +manolis +manolo +manon +manou +manouch +manoukia +mansbrid +mansell +manshih +mansi +manson +mansour +mansoura +mansouri +mansukha +mansum +mantell +manto +manuel +manuela +manus +manuszak +manverse +manwarin +manya +mao +maohua +mapes +mapile +mapp +mar +mara +marabel +maracle +maragoud +marano +marasco +marasliy +marc +marc-and +marc-ant +marcanti +marcanto +marce +marceau +marcel +marcela +marcelia +marcelis +marcella +marcelle +marcelli +marcello +marcellu +marcelo +marcey +march +marchall +marchand +marchant +marcheck +marchell +marchese +marchett +marci +marcia +marcie +marcile +marcilie +marcille +marciniu +marcio +marco +marcom +marconi +marcos +marcotte +marcoux +marcum +marcus +marcy +mardi +marea +mareah +marek +marella +maren +marena +marenger +maressa +marette +marg +marga +margalit +margalo +margaret +margarie +margarit +margaux +marge +margeaux +margery +marget +margetso +margette +margheri +margheti +margi +margie +margit +margitta +margo +margot +margret +margriet +margueri +margy +marhta +mari +maria +mariabel +mariaele +mariaisa +mariam +marian +mariana +mariani +mariann +marianna +marianne +maribel +maribell +maribeth +marice +maridel +marie +marie-an +marie-je +marie-jo +marie-lu +marie-na +marieann +mariejea +marieka +marieke +mariel +mariela +mariele +marielle +mariesar +mariet +marietta +mariette +marigold +marijke +marijn +marijo +marika +mariland +marilee +marilin +marillin +marily +marilyn +marilynn +marin +marina +marinaro +marineau +marinett +marinna +marino +marinos +mario +marion +mariotti +mariou +mariquil +maris +marisa +marisca +marisela +mariska +marissa +marit +marita +maritan +maritsa +maritza +marius +mariya +mariza +marj +marja +marjan +marje +marjean +marjet +marji +marjie +marjo +marjoke +marjolei +marjorie +marjory +marjy +mark +markell +markes +marketa +marketin +markham +markiewi +markins +markland +markle +markm +markmeye +marko +markos +markovic +marks +markus +marla +marlaine +marlane +marleah +marleau +marlee +marlee-j +marleejo +marleen +marlena +marlene +marley +marlie +marlies +marlin +marline +marling +marlo +marloes +marlon +marlow +marlowe +marlsela +marlyn +marlyne +marmaduk +marmen +marmillo +marmion +marna +marne +marneris +marney +marni +marnia +marnie +marnix +maroucho +maroun +marples +marquart +marquez +marquita +marr +marra +marrec +marren +marrett +marriet +marrilee +marriott +marris +marrissa +marron +marrone +marryann +mars +marscha +marschew +marsden +marsh +marsha +marshal +marshall +marshaus +marshman +marsie +marsiell +marson +marston +mart +marta +martainn +martel +martell +martelle +marten +martens +martenso +martenst +martguer +martha +marthe +marthena +marti +martica +martie +martijn +martin +martina +martince +martinci +martine +martinea +martinez +marting +martino +martins +martinus +martita +marttine +marturan +marty +martyn +martynne +marui +marum +maruszak +maruszew +marv +marve +marvel +marvell +marven +marvette +marvin +marwan +marwin +marx +mary +mary-ann +mary-ell +mary-jan +mary-jo +mary-mic +mary-pat +marya +maryak +maryam +maryann +maryanna +maryanne +marybell +marybeth +maryelle +maryjane +maryjo +marykate +marykay +maryl +marylee +marylin +marylind +marylou +marylynn +marymarg +maryn +maryrose +marys +marysa +maryse +marysue +maryvonn +marzella +marzullo +masa +masahiro +masako +masales +masamich +masanao +masanobu +masao +masapati +masaru +masciare +masha +mashura +masika +masini +maskell +maskery +maslen +maso +mason +masood +masooda +masotti +masoud +masse +massengi +massey +massicot +massimil +massimo +massinga +masson +massone +massonne +massoud +massoudi +massumi +mastella +mastenbr +masterpl +masters +masterso +mastrang +mastroma +mastrona +masty +mat +mata +mataga +matalon +matatall +matelda +mateo +materkow +materna +mathe +mather +matheson +mathew +mathews +mathewso +mathian +mathias +mathieso +mathieu +mathilda +mathilde +mathis +mathiue +mathur +mathurin +mathus +matias +matibag +matilda +matilde +matlock +matney +matrin +matrundo +mats +matson +matsubar +matsugu +matsunag +matsushi +matsuzak +matsuzaw +matt +matteau +matteo +mattes +matthaeu +mattheus +matthew +matthews +matthias +matthieu +matthiew +matthus +matti +mattias +mattie +mattiuss +mattiuz +matton +mattson +matty +matusik +mau +mau-pin +mauck +maud +maudalis +maude +maudie +maudrie +maudy +mauer +mauldin +maund +mauney +maunu +maupin +maura +maure +maureen +maureene +maurene +maurer +maurice +mauricio +maurie +maurijn +maurine +maurino +maurise +maurita +maurits +maurizia +maurizio +mauro +maury +maveety +mavis +mavra +mavrou +mawani +mawji +mawst +max +maxey +maxi +maxie +maxim +maximili +maximo +maxin +maxine +maxseine +maxsom +maxwell +maxy +may +maya +mayaram +mayasand +maybee +maybelle +maycel +maycock +maye +mayea +mayenbur +mayer +mayes +mayeul +mayfield +mayhugh +mayi +maylynn +mayman +maynard +mayne +maynes +maynie +maynord +mayo +mayor +mayoux +mayr +mayr-ste +mays +mayumi +mayya +mazahir +mazanji +mazarick +mazen +mazey +mazlack +mazurek +mazyar +mazzei +mbrose +mc +mc alpin +mc ginn +mcadam +mcadams +mcadorey +mcafee +mcalear +mcaleer +mcaliste +mcallist +mcallum +mcandrew +mcarthur +mcateer +mcaulay +mcauliff +mcbeth +mcbrayne +mcbride +mcbroom +mcbryan +mccabe +mccaffer +mccaffit +mccaffre +mccaig +mccain +mccall +mccalla +mccallen +mccallum +mccampbe +mccandle +mccann +mccarrel +mccarrol +mccarron +mccarthy +mccartin +mccartne +mccarty +mccaughe +mccauley +mccaw +mcclain +mcclarre +mcclary +mcclean +mccleery +mcclella +mcclendo +mcclenno +mcclinto +mccloske +mcclough +mcclure +mccluske +mcclymon +mccoll +mccollam +mccollum +mccolman +mccomb +mccombs +mcconagh +mcconkey +mcconnel +mcconney +mccord +mccorkel +mccorkle +mccormac +mccormic +mccorquo +mccoy +mccoy-ca +mccracke +mccrain +mccraney +mccray +mccready +mccreano +mccrear +mccreath +mccreesh +mccrimmo +mccuaig +mccue +mccullen +mcculloc +mccullog +mccullou +mccully +mccune +mccurdy +mccusker +mcdade +mcdaniel +mcdavitt +mcdermot +mcdevitt +mcdonald +mcdonnel +mcdonoug +mcdoom +mcdougal +mcdowall +mcdowell +mcduffie +mcdunn +mceacher +mcelderr +mcelhone +mcelligo +mcellist +mcelrea +mcelroy +mcevoy +mcewan +mcewen +mcewen-r +mcfadden +mcfall +mcfarlan +mcfeely +mcgallia +mcgarry +mcgaughe +mcgee +mcgehee +mcgeown +mcghee +mcgill +mcgillic +mcgillvr +mcgilly +mcginley +mcginn +mcglynn +mcgonagl +mcgoniga +mcgorman +mcgough +mcgovern +mcgowan +mcgracha +mcgrath +mcgregor +mcgruder +mcguigan +mcguinne +mcguire +mcgurn +mchale +mchan +mcharg +mchugh +mcilrath +mcilroy +mcinerne +mcinnis +mcintee +mcintire +mcintomn +mcintosh +mcintyre +mcisaac +mciver +mckay +mckeage +mckeague +mckearne +mckechni +mckee +mckeegan +mckeen +mckeigha +mckeitha +mckenna +mckenney +mckenzie +mckeone +mckeown +mckerrow +mckibben +mckibbin +mckibbon +mckie +mckillop +mckinlay +mckinley +mckinney +mckinnon +mcknelly +mcknight +mckusick +mclachla +mclaren +mclauchl +mclaughl +mclawhon +mclawhor +mclean +mclellan +mclemore +mclenagh +mclendon +mclennan +mcleod +mcluskie +mcmahan +mcmahon +mcmanis +mcmann +mcmannen +mcmanus +mcmaster +mcmeegan +mcmenami +mcmichae +mcmillan +mcmillen +mcmillia +mcmillio +mcminn +mcmonagl +mcmullen +mcmullin +mcmurray +mcnab +mcnabb +mcnair +mcnally +mcnamara +mcnamee +mcnaught +mcneal +mcnealy +mcneely +mcneese +mcneil +mcneill +mcneilly +mcnerlan +mcnerney +mcnichol +mcnicol +mcniel +mcnitt +mcnulty +mcphaden +mcphail +mcphee +mcpherso +mcquaid +mcquaig +mcquarri +mcqueen +mcrae +mcrann +mcready +mcritchi +mcronald +mcruvie +mcshane +mcsheffr +mcsorley +mcsween +mctaggar +mctavish +mctierna +mcturner +mcvay +mcveety +mcveigh +mcvey +mcvicar +mcvicker +mcwalter +mcwaters +mcwherte +mcwhinne +mcwhorte +mcwilton +mdhazali +mduduzi +me +meachum +mead +meade +meadows +meads +meagan +meaghan +meagher +mealin +meany +meara +measures +mebine +mechael +meche +mechelle +mecher +meckler +meckley +mecteau +medefess +medeiros +medel +meder +medill +medioni +medlin +medlock +mednick +medria +meehan +meeks +meena +meenaksh +meer +meera +meerveld +meese +meeting +meffe +meg +megan +megen +meggi +meggie +meggitt +meggo +meggy +meghan +meghani +meghann +megumi +mehboob +mehd +mehdi +mehelis +mehetabe +mehlhaff +mehmet +mehmud +mehrdad +mehrez +mehrzad +mehta +mehul +mei +mei-jywa +meier +meijer +meijywan +meikle +meilleur +mein +meining +meir +meiser +meisner +meissner +mejdal +mejia +mejury +mel +mela +melamie +melana +melani +melania +melanie +melanson +melantha +melany +melaura +melba +melberta +melbourn +meldia +meldrum +melecio +meleg +melek +melesa +meleski +meleskie +melessa +meletios +melfi +melford +melhem +meli +melicent +melina +melinda +melinde +melinie +melisa +melisand +melisend +melisent +melissa +melisse +melita +melitta +melkild +mella +melli +mellicen +mellie +mellisa +mellisen +mello +melloney +mellor +melly +melnyk +melodee +melodie +melody +meloling +meloney +melonie +melony +melosa +melton +melucci +melva +melvin +melvina +melvyn +melynda +men-kae +menaker +menard +menasce +menashi +menashia +mencer +mendel +mendelso +mendez +mendie +mendolia +mendonca +mendorf +mendoza +mendy +menechia +menendez +meng +mengly +menkae +menke +menna +mennie +menon +menqiong +mensch +mensinka +mentor +menyhart +menzel +menzies +mer +merat +merb +mercedes +mercer +merci +mercie +mercier +mercilin +mercy +merdia +meredeth +meredith +merell +merelyn +meres +mereu +meriann +meridel +meridew +meridian +meridith +meriel +merilee +meriline +merill +merilyn +merinder +meris +merissa +meriweth +merizzi +merkling +merl +merla +merle +merlin +merlina +merline +mermelst +merna +merola +merralee +merrel +merrett +merrick +merridie +merrie +merriell +merrile +merrilee +merrili +merrill +merrills +merrils +merrily +merrithe +merritt +merry +merryman +merrywea +mersch +mersey +mersinge +merton +merunix +merv +mervin +mervyn +merwin +merworth +merwyn +meryl +merylene +meseberg +mesirov +meskimen +mesko +mesquita +message +messer +messeria +messersc +messick +messier +messinge +mesut +meszaros +meta +metcalf +metcalfe +metelski +meter +metheny +metherel +methi +methiwal +methot +methul +metin +metler +metraile +metrics +mettrey +metyn +metz +metzger +metzler +meubus +meunier +mevis +mewa +meyer +meyerink +meyers +meza +mezzano +mezzoius +mfgeng +mgmt +mia +miao +miasek +mic +micaela +micah +micahel +miceli +micgael +micha +michael +michael- +michaela +michaeli +michaell +michaels +michaez +michail +michailo +michal +michale +michalos +michaud +micheal +micheil +michel +michele +michelin +michell +michella +michelle +michels +michelse +michelso +michelus +michi +michial +michie +michiel +michigan +michihir +michiko +michiya +mick +mickens +mickey +micki +mickie +micklos +micky +mico +micro +microfab +micucci +mid +middlebr +middleto +midge +midha +miek +mielke +miello +miep +miernik +miers +mierwa +mietek +miezitis +migdalia +mignault +mignon +mignonne +miguel +miguela +miguelit +mihaela +mihai +mihail +mihan +mihara +mihir +mihm +miho +mihran +mika +mikael +mikaela +mike +mikel +mikelis +mikeloni +mikey +mikhail +miki +mikie +mikihito +mikkel +miklos +miko +mikol +miksik +mikulka +mil +mila +milaknis +milakovi +milan +milanfar +milanovi +milar +milary +milburn +mildred +mildrid +mile +milena +miles +milford +milian +milicent +milind +milinkov +milissen +milka +mill +millaire +millar +millard +millen +miller +millero +millerwo +millette +milletti +milli +millicen +millie +milligan +millisen +millo +milloy +mills +millspau +millward +milly +milman +milmine +milne +milner +milo +milor +milotte +milou +milstead +milston +milt +miltenbu +miltie +milton +miltos +milty +milutino +milway +milzie +mim +mimi +mims +min +min-jho +mina +minai +minakata +minako +minami +minard +minas +mincey +minck +minda +mindy +minegish +miner +minerva +minesh +minetola +minetta +minette +ming +ming-cha +ming-chu +ming-hue +ming-min +ming-pin +ming-tzo +ming-yua +mingchu +minghuei +minghwan +mingpin +mingtzon +mingyuan +minh +minh-phu +minhwi +minichil +minjho +minkus +minna +minnamin +minne +minnesot +minni +minniche +minnie +minnnie +minny +minor +minority +minoru +minos +minshall +minsky +minta +minter +minthorn +minyard +minzhu +miodrag +miof mel +mior +miotla +mips +miquela +miquette +mir +mira +mirabel +mirabell +miran +miranda +mirande +mirarchi +mireiell +mireille +mirek +mirella +mirelle +mirenda +miriam +miriamne +mirian +mirilla +mirin +mirjam +mirko +mirna +miro +miron +miroslav +miroslaw +mirza +mis +misbah +mischa +misczak +misha +mishina +misium +miskelly +misko +misra +miss +missagh +missaili +missie +misslitz +missy +mister +misti +mistry +mistulof +misty +misutka +mitalas +mitch +mitchael +mitchel +mitchell +mitchels +mitchler +mitesh +mitra +mitrani +mitrou +mitsui +mitsuko +mitsuru +mitsuter +mittleid +mitzi +mivehchi +miwa +miwako +miyuki +mizerk +mkpwent +mkt +mlacak +mlcoch +mmail +mmdf +mnason +mo +moataz +moazzami +mobasher +mobley +mocock +modaffer +modesta +modestia +modestin +modestos +modestou +modesty +modh +modi +modigh +modl +modotto +modula-2 +modula2 +moe +moebes +moel +moen +moening +moeschet +moetteli +moffatt +moffet +moffett +mofina +moghe +moghis +mogridge +moh +moh'd +mohajeri +mohamad +mohamed +mohammad +mohammed +mohan +mohandas +mohandis +moharram +mohd +mohideen +mohler +mohr +mohrmann +mohsen +moina +moira +moise +moises +moishe +moiz +moizer +moja +mojgani +mojtaba +mok +mok-fung +mokbel +mokhtar +mokros +molani +moledina +moleski +moll +mollee +mollerus +molli +mollie +molloy +molly +molnar +molochko +moloney +molson +molyneux +mombourq +mommy +momon +momtahan +mona +monachel +monaco +monaghan +monah +monahan +monardo +moncef +moncion +monck +moncur +mondher +mondor +monet +monette +moneypen +monforto +monfre +mong +mong-tse +mongo +mongtsen +moni +monica +moniek +monika +monino +monique +moniter +monling +monn +monro +monroe +monson +montag +montague +montaldo +montanez +montange +montanin +montcalm +monte +monteene +monteggi +monteiro +montelli +montero +monteros +montgome +monti +montijo +montmore +montor +montoute +montoya +montreal +montreui +montsion +montsko +montuno +monty +mony +monzo +moo-youn +mooder +moogk +mooken +moomey +moon +moondog +mooney +moonistt +moorcrof +moore +moore-vi +moorefie +moorer +moores +moorhous +moosavi +mora +moraetes +morais +morales +moran +moray +morcinel +mord +mordecai +mordecha +morden +mordy +morearty +moreau +moree +moreen +morehead +morek +moreland +morelli +morena +moreno +moreton +morettin +morey +morgan +morgan-c +morgana +morganic +morganne +morgen +morglan +morgun +mori +moria +moriarty +morie +morimoto +morin +morini +morino +moris +morishig +morissa +morisset +moritz +moriyama +morlee +morley +morly +morna +morneau +morocz +moroney +moroz +morra +morreale +morrie +morrin +morris +morrison +morrisse +morry +morse +morson +mort +morten +morteza +mortie +mortimer +morton +morty +moschopo +mose +moseby +moselle +moser +moses +mosetta +moshe +moshinsk +moshiri +moshtagh +moshy +mosis +moskalik +mosley +moss +mostafa +mostovac +mosur +mot +motashaw +motasim +motaz +mote +motoko +mototsun +mott +motwani +mou +moua +mouat +moubarak +mougy +mouillau +moulds +moulsoff +moulton +mouna +mounir +mountfor +moussa +mousseau +moussett +moveline +movies +mowat +mowbray +mowle +moxham +moxley +moxon +moy +moya +moyano +moyce +moyenda +moyer +moyers +moyes +moyna +moynihan +moyoli +moyra +mozart +mozek +mozelesk +mozelle +mozes +mraz +mrozinsk +mrugesh +msg +mshia +mtcbase +mtl +mtlipadm +mtnview +mu-cheng +mucci +mucheng +muchow +mucklow +mudd +mudge +mudie +mudry +muehle +mueller +muenster +muffin +mufi +mufinell +mufti +mugniot +muh-cher +muhammad +muhammed +muhchern +mui +muinck +muir +muire +muise +mujahed +mukai +mukhar +mukharji +mukherje +mukhopad +mukul +mukund +mulder +mulders +muldoon +mulero +mulherka +mulholla +mullaly +mullaney +mullarne +mullen +muller +mullett +mullin +mullinix +mullins +mulmuley +mulot +mulqueen +mulroney +multispe +mulumba +mulvie +mumford +mumma +mummy-cr +mun-choo +mun-hang +munaz +munchoon +munden +mundi +mundy +muni +munikoti +munir +muniz +munjal +munmro +munn +munna +munns +munro +munroe +munsey +munson +munter +munz +munzer +muqarrab +muradia +muralidh +murash +murat +murawski +murchiso +murdaugh +murdeshw +murdoch +murdock +mureil +murial +muriel +murielle +murison +murnagha +muro +murock +murph +murphin +murphy +murphy-k +murray +murrell +murrill +murrin +murris +murry +murson +murtagh +murtaza +murthy +murton +murty +murveit +murvyn +musa +musca +musclow +muselik +musen +musgrove +musick +mussalle +mussar +musselwh +musser +mustafa +mustapha +mustillo +mutcher +muth +muthuswa +mutikain +mutsuo +muttaqi +muus +muzio +muzz +mwa +my +mya +myatt +myca +mycah +mychal +myer +myers +myers-pi +myhill +mykitysh +myla +myles +mylne +mylo +mymryk +myong +myoung +myra +myrah +myranda +myre +myriam +myrick +myrilla +myrillas +myrle +myrlene +myrna +myron +myroon +myrta +myrthill +myrtia +myrtice +myrtie +myrtille +myrtle +myrvyn +myrwyn +mysore +mystkows +myung +myunghee +myungho +nabeel +nabeil +nabil +nabisco +nabors +nace +nachtshe +nachum +nad +nada +nadav +nadean +nadeau +nadeau-d +nadeem +nadeen +nadel +nader +nadezhda +nadi +nadia +nadim +nadine +nadir +nadiya +nadler +nadolny +nadon +nady +nadya +nae-ming +naem +naeming +nafezi +nafsika +nagai +nagaraj +nagaraja +nagaratn +nagarur +nagel +nagendra +nagenthi +nagle +naguib +nagy +nagys +nahabedi +nahas +nahata +nahid +nahmias +nahornia +nahum +naile +naim +naima +naimpall +nair +nairn +naismith +najafi +naji +najib +nakagome +nakamura +nakano +nakatsu +nakhla +nakhoul +nakina +nakonecz +nalani +naldrett +nalin +nall +nallenga +nam +nam-kiet +nam-soo +namasiva +nambride +namdar +nami +namiki +namont +namrata +nan +nana +nanamiya +nananne +nance +nancee +nancey +nanci +nancie +nancy +nancyjea +nanda +nandakum +nandan +nandi +nandita +nando +nanete +nanette +nang +nani +nanice +nanine +nannette +nanni +nannie +nanny +nanon +naohiko +naoko +naolu +naoma +naomi +naor +naoto +naoum +nap +naparst +napert +naphan +naphtali +napier-w +napke +napoleon +napper +nappie +nappy +nara +narasimh +narayan +narayana +narciso +narda +nardiell +naren +narendra +naresh +nari +narida +nariko +narinder +naro +narraway +narrima +narron +narsimha +nasato +naser +nash +nashib +nashif +nashir +nashvill +nasir +nason +nass +nasser +nassoy +nassr +nasvin +nat +nata +natal +natala +natale +natalee +natalia +natalie +natalina +nataline +nataliya +nataly +natalya +nataniel +nataraja +natascha +natasha +natasja +natassia +natassja +natchez +nate +nath +nathalia +nathalie +nathan +nathanae +nathania +nathanie +nathanil +nathoo +national +natiuk +nativida +natka +natkin +natraj +natty +natver +natvidad +naufal +naugle +naujokas +naujoks +nault +nava +naval +navalta +navaratn +navarre +navarro +naveda +naveed +naveen +navid +navneet +nawa +nawaby +nayan +naybor +naylor +nayman +nayneshk +nayyer +nazanin +nazardad +nazeh +nazi +nazib +nazli +nazman +ncc +ndububa +ne-regio +neal +neala +neale +neall +nealon +nealson +nealy +neamtu +neander +nearing +nearyrat +neate +neault +nebel +ned +neda +nedda +nedderma +neddie +neddy +nedi +needham +neefs +neel +neely +neena +neene +neeraj +neetu +nefen +neff +negandhi +negar +neghabat +negrich +nehemiah +nehring +neibauer +neider +neidy +neifert +neil +neila +neile +neill +neilla +neille +neilly +neils +neilsen +neilson +neisius +neitzel +nekueey +nel +nelda +nelia +nelie +nelken +nell +nelle +nelleke +nelli +nellie +nellis +nelly +nelon +nels +nelsen +nelson +nemec +nemes +nemeth +nemirovs +nenad +neng-chu +nengchun +nentwich +neoh +nerby +nereida +nerem +nerissa +nerita +nermana +nero +neron +nert +nerta +nerte +nerti +nertie +nerty +nesbitt +nesralla +ness +nessa +nessi +nessie +nessman +nessy +nesta +neste +nester +nestor +netas +netdbs +netdev +netherso +netick +netlink +neto +netta +netteam +netti +nettie +nettle +nettles +netto +netty +network- +networkr +networks +netzke +neubauer +neudeck +neufeld +neuman +neumann +neumeist +neureuth +neuschwa +neustift +neusy +nev +neva +nevardau +nevein +nevil +nevile +neville +nevin +nevins +nevison +nevrela +nevsa +new +newberry +newbold +newby +newcomb +newcombe +newell +newham +newhook +newkirk +newlab +newland +newman +newnam +newport +news +newsom +newsome +newton +neyer +neyman +neyra +neysa +neywick +nezm +nezon +ng +nga +ngai +ngai-nga +ngaingai +ngan +nghia +ngina +ngo +ngoc +ngocquyn +nguy +nguyen +nguyen-t +nguyet +nha +nhan +nhat +nhien +nhut +nial +niall +niamh +nic +nicandro +nicas +niccolls +niccolo +nichael +nichol +nicholas +nichole +nicholl +nicholle +nichols +nicholso +nici +nick +nickell +nickells +nickels +nickerso +nickey +nicki +nickie +nickle +nicklin +nicko +nickola +nickolai +nickolas +nickolau +nickonov +nicky +nico +nicol +nicola +nicolai +nicolais +nicolaou +nicolas +nicole +nicolea +nicoles +nicolett +nicoli +nicolina +nicoline +nicolis +nicolle +nicolo +nicolopu +nicosia +nie +niebudek +niedelma +niedens +niedra +niedzwie +niek +niel +nield +niels +nielsen +nielson +niemi +nien +nien-hwa +nienhwa +niepmann +niepokuj +niergart +nigam +nigel +nihar +nijen +nik +nikaniki +nike +nikfarja +niki +nikifori +nikiforu +nikita +nikki +nikkie +nikky +niklas +niko +nikola +nikolai +nikolaos +nikolas +nikolaus +nikolett +nikolia +nikolopo +nikolos +nikos +nil +nilakant +niles +nilesh +nill +niloofar +nils +nilson +nilsson +nima +nimesh +nimish +nimmo +nimr +nimzod +nina +ninetta +ninette +ninety-o +ning +ninja +ninnetta +ninnette +ninno +ninon +nipper +nir +niraj +niranjan +nirmal +niro +nirwan +nisa +nisan +nisbet +nischuk +nishan +nishida +nishiguc +nishihar +nishimot +nishimur +nishioka +nishith +nishiwak +nishiyam +nishizak +nissa +nisse +nissie +nissy +nita +nital +nitin +nitschky +nitza +nitzhe +nitzhye +niu +niven +nix +nixie +nixon +nixxon +niz +nizam +nizamudd +nizar +nizman +nj +njo +nnamdi +nnamudi +no +noach +noah +noak +noam +noami +nobe +nobel +nobes +nobie +noble +nobuko +nobuo +nobutaka +nobuto +nobuyuki +noby +nock +noddin +node +noe +noel +noelani +noell +noella +noelle +noellyn +noelyn +noemi +noeschka +noffke +noguchi +nokes +nola +nolan +nolan-mo +nolana +noland +nolen +noles +nolet +nolie +noll +nollie +nolly +nolter +nomi +nomura +nona +nonah +noname +nong +nongqian +noni +nonie +nonkes +nonna +nonnah +noone +noorani +noorbehe +noorbhai +nooshin +nopi +nora +norah +noralie +noraly +norbert +norberto +norbie +norby +norcal +norczen +nordskog +nordstro +norean +noreen +norel +norena +norene +norfleet +norgaard +norikats +norikazu +noriko +norina +norine +norio +noris +norm +norma +norman +normand +normandi +norment +normie +normy +norndon +noronha +norri +norrie +norris +norry +norstar +north +northam +northcot +northrop +northrup +norton +norval +norvie +norvig +norwood +nosewort +noslab +nosov +nostrada +notley +nou +noubar +nouira +noujeim +nour +nouri +nova +novak +novelia +novene +novia +novisedl +novo +novorols +novotny +nowak +nowell +nowina-k +nowlin +noy +noye +noyes +npi +nss +ntelpac +ntinash +ntlc +ntpadmin +ntprel +nttest +nuber +nuetzi +nugent +number +nunes +nunez +nung +nunn +nunnally +nuno +nunold +nuntel +nurettin +nurhan +nuri +nuria +nurly +nurmi +nuttall +nuvit +nyaguthi +nyberg +nyce +nydia +nye +nyeita +nyenhuis +nyland +nyquist +nyre +nys +nyssa +o karina +o'brecht +o'brian +o'brien +o'carrol +o'colmai +o'connel +o'conner +o'connor +o'dacre +o'dale +o'dea +o'dell +o'dohert +o'donnel +o'donova +o'dwyer +o'farrel +o'grady +o'hagan +o'hara +o'hearn +o'heocha +o'higgin +o'keefe +o'keeffe +o'kelly +o'leary +o'malley +o'meara +o'murchu +o'neal +o'neall +o'neil +o'neill +o'regan +o'reilly +o'rourke +o'shaugh +o'shea +o'sulliv +o'toole +o_kelly +oakland +oakley +oaks +oam +oanes +oanh +oastler +oates +oaul +obadiah +obadias +obed +obeda +obediah +obeidat +obenauf +ober +oberhamm +obermeie +obermeye +obermyer +oberon +oberpril +obidiah +obie +oblak +obrecht +obrien +obrusnia +oby +ocampo +ochman +ochoa +ochs +ocone +oconnor +octavia +octavio +octavius +odac +odden +ode +odecki +oded +odegaard +odele +odelia +odelinda +odell +odella +odelle +oden +odessa +odetta +odette +odey +odgers +odie +odile +odilia +odille +odo +odum +ody +oedipal +oedipus +oertelt +oesterre +oestreic +oetting +oey +ofcparm +ofcparms +ofelia +ofella +ofer +offers +ofilia +ogan +ogborn +ogburn +ogdan +ogden +ogdon +ogilvie +oglesby +ogrodnik +ogua +oguz +ohala +ohandley +ohara +ohare +ohashi +ohio +ohmaru +ohmayer +ohn +ohsone +oingres +oivind +ojala +ojerholm +oka +okada +okafo +okai +okamoto +okan +okay +oke +okey +okon +okrafka +oksana +okseniuk +oktar +okun +okura +okuyama +okuzawa +ola +olag +olav +old +older +oldfield +oldham +oldright +ole +oleesa +oleksysh +olenka +olenolin +olesen +olesko +olga +olia +olimpia +olin +olinger +olinyk +olivares +olive +oliveira +oliver +olivero +olivette +olivia +olivie +olivier +oliviero +oliy +olken +ollie +olliff +olly +olmstead +olness +olof +olsen +olsheski +olson +olszewsk +olusola +olva +olvan +olwen +olympe +olympia +olympie +olynyk +oma +oman +omar +omayma +omer +omerine +omero +omid +omori +omura +omyeabor +onassis +onder +ondovcik +ondrea +oneida +oneto +onette +onfre +onfroi +ong +onge +onida +onofredo +onsitete +onsy +onufrak- +onyshko +ooi +oona +oorschot +oost +op +opal +opalina +opaline +opalski +open +oper +operatio +operator +ophelia +ophelie +oplinger +opperman +ops +opsplng +optimiza +opto +opus +ora +oral +oralee +oralia +oralie +oralla +oralle +oran +orazio +orbadiah +orca +ord +ordas +orden +orders +ordog +ordway +oreffice +oreilly +orel +orelee +orelia +orelie +orella +orelle +oren +orenzo +orfano +orford +organiza +orgren-s +oriana +orie +orin +orion +orla +orlan +orland +orlando +orly +orlyn +orme +ormesher +ormsby +ornburn +ornelas +orol +oros +orpheus +orr +orran +orren +orrin +orsa +orser +orsini +orsola +orson +ortensia +orth +ortiz +orton +orv +orville +orvin +orwell +oryal +osada +osadciw +osama +osami +osatuik +osbert +osborn +osborne +osbourn +osbourne +osburn +oscar +osgood +oshinski +oshiro +osiakwan +osiris +oskar +oskorep +oslund +osman +osmond +osmund +osofsky +ossama +ossie +ostapiw +ostarell +ostaszew +oster +osterber +osterhou +osterman +ostifich +osvaldo +oswald +oswalt +oswell +otakar +otani +otec +otes +otfried +otha +othelia +othella +othello +othilia +othilie +otho +othon +otis +otmar +otsuka +ott +ottawa +ottco +ottcsr +otter +ottilie +otto +ottoman +ottosson +ottowa +oturakli +otway +oucharek +oue +ouellet +ouellett +ouimet +ousterho +outage +outhwait +outram +ouzas +ovans +ovas +overby +overcash +overdyke +overton +oviedo +ovila +owen +owens +owensby +owsiak +oxendine +oyama +oyung +ozalp +ozan +ozay +ozer +ozersky +oziemblo +oziskend +ozkan +ozlem +ozmizrak +ozmore +ozselcuk +ozyetis +ozzie +ozzy +paar +pablo +pac +pace +pacey +pachal +pacheco +pachek +pachner +pachulsk +pacific +packager +packard +packston +paco +pacon +pacorro +paczek +paczynsk +paddie +paddon +paddy +paden +padget +padgett +padiath +padilla +padiou +padma +padmanab +padraic +padraig +padriac +paerio +paes +paetsch +pafilis +pagani +page +pageau +paget +pagi +paglia +pagliaru +pai +paialung +paige +paik +pail +paine +painter +painters +pak +pak-jong +pak-kin +pakkin +paksi +pakulski +pal +palacek +palamar +palasek +palczuk +palermo +paley +palfreym +palidwor +paliga +palik +paliwal +pall +pallab +pallen +palm +palme +palmer +paloma +palomar +paluso +pam +pambianc +pamela +pamelina +pamella +pammi +pammie +pammy +pamperin +pan +panacea +panagiot +pancewic +panch +panchen +panchito +panchmat +pancho +pancholy +pandey +pandolfo +pandora +pandrang +pandya +panek +panesar +pang-chu +pangchun +panger +pangia +panizzi +pankaj +panke +pankesh +pankhurs +pankiw +panko +pankratz +pannell +panolil +panos +panosh +pansie +pansy +pantages +pantalon +pantas +pantelis +panter +panton +panzer +pao +pao-ta +paola +paoletti +paolina +paolo +paone +paota +papa +papadopu +papagena +papageno +papageor +papahadj +papajani +papalits +papanton +paparell +pape +paper +paperno +papers +papiez +papineau +papp +pappas +papper +pappu +paprocki +paqs +paquette +paquin +paquito +par +para +paracha +paradis +paradise +parasili +pardeep +pardi +pardip +pardo +parekh +paresh +parham +parhi +parichay +parihar +parikh +paris +parise +parisen +parisi +parisien +park +parkash +parke +parker +parker-s +parkes +parkhill +parkin +parkins +parkinso +parks +parkson +parlett +parmakse +parman +parmar +parmente +parmigia +parminde +parn +parnas +parnell +parniani +parnigon +parow +parr +parra +parrilli +parrillo +parris +parrish +parrish- +parrnell +parrott +parry +pars +parsifal +parsloe +parsons +part +partap +partello +partha +parthasa +partick +partin +partlo +parton +partovi +paruleka +parveen +parvin +parviz +paryag +parypa +pas +pascael +pascal +pascale +pascali +pascas +paschall +pasher +pashia +pashmine +pasiedb +pasquale +passier +passin +pasterna +pastore +pastorek +pastuszo +pasvar +pat +patacki +patadm +patch +patchcor +patches +patchett +patching +patchit +patchor +patchsqa +patcor +pate +patel +paten +patenaud +paterson +patey +pathak +patience +patin +patner +pato +patoka +paton +patoskie +patra +patriarc +patric +patrica +patrice +patrice- +patricem +patrici +patricia +patricio +patrick +patrizia +patrizio +patriziu +patry +patsy +patt +patte +patten +patterso +patteson +patti +pattie +pattin +pattison +patton +pattra +pattullo +patty +patwardh +pau +paul +paula +paulas +paule +paulett +pauletta +paulette +pauley +paulhus +pauli +paulich +paulie +paulien +paulin +paulina +pauline +pauling +paulinus +paulita +paulk +paulo +paulovic +paulus +pauly +paunins +pautenis +pavan +pavel +pavia +pavitt +pavla +pavlic +pavlov +pavlovic +pawel +pawelchu +pawlikow +pawliw +paxon +paxton +paye +payette +paylor +payn +payne +paynter +payroll +payton +pazos +pbkim +pbx +pcboards +pcbtools +pcsuppor +pcta +pde +pdesuppo +peabody +peacemak +peach +peacocke +peadar +peake +pearce +pearcy +pearl +pearla +pearle +pearline +pearse +pearson +peart +peate +peaugh +peavoy +pebrook +pecic +peckel +peckett +peder +pederson +pedigo +pedley +pedneaul +pedram +pedriana +pedro +peebles +peedin +peerman +peers +peeters +peets +peg +pegasus +pegeen +peggi +peggie +peggy +pegler +pehong +pei-chie +pei-ling +peiling +peirce +peiser +peixoto +peleato +pelegri +pelissie +pelkie +pell +pelland +pellegri +pelletie +pellizza +pellizze +pelly +pelosi +pelot +pelton +peluso +pelz +pembroke +pen +pen-mi +pen-min +pena +pena-fer +penang +pendergr +pendhark +pendleto +penelopa +penelope +penfield +peng +peng-dav +penland +penmi +penmin +penn +pennebak +pennell +penner +penney +penni +pennie +penninge +penningt +penny +penrod +penrose +pension +peon +peoples +pepc +pepe +pepi +pepillo +pepin +pepita +pepito +pepler +pepper +pepple +peptis +per +pera +peralta +perazzin +perceval +perchtho +percival +percy +peregrin +pereira +perenyi +perez +perfetti +peri +peria +pericak +perice +pericles +perina +perkin +perkins +perkinso +perl +perla +perle +perleber +perlmutt +pernell +perng +perona +peroxra +perras +perrault +perreaul +perrella +perren +perri +perrier +perrin +perrine +perron +perrotta +perry +perryman +perryno +persaud +perschke +persechi +pershing +persis +personna +peschke +pesik +pesold +pestill +pet +peta +petar +pete +peter +peterman +peters +petersen +peterson +peterus +petey +petillio +petr +petra +petrakia +petras +petre +petrea +petree +petrescu +petretta +petrey +petri +petrick +petrie +petrina +petrinac +petro +petronel +petronia +petronil +petrovic +petruck +petrunew +petrunka +petschen +petter +pettinge +pettitt +petunia +petzold +pevec +pevzner +pewitt +pey-kee +peyman +peyter +peyton +pezzoli +pezzoni +pezzullo +pfeffer +pfeilsch +pfieffer +pfifferl +pfitzner +pflughau +phaedra +phagan +phaidra +phair +phal +phalen +phalpher +pham +phan +pharr +pharris +phat +phebe +phedra +phelan +phelia +phelps +phifer +phil +philbeck +philbert +philion +philip +philipa +philippa +philippe +philippi +philippo +philips +philis +phill +phillida +phillie +phillip +phillipe +phillipp +phillips +phillis +philly +philomen +philp +phineas +phip +phipps +phiroze +phoebe +phoenix +phonenet +phong +phoung +phu +phuc +phung +phuoc +phuong +phuong-l +phuongli +phyl +phylis +phyllida +phyllis +phyllys +phylys +physical +pi-yu +pia +piasecki +piatt +pic +picard +piche +pichocki +pick +pickens +pickett +pickles +piecaiti +piecowye +piel +pien +pier +piercarl +pierce +piercey +piercy +pierette +piero +pieron +pierosar +pieroway +pierre +pierre-a +pierre-h +pierre-m +pierre-y +pierret +pierrett +pierrick +piersol +pierson +piete +pieter +pietra +pietrek +pietro +pietromo +pietropa +pietrzak +piette +pifko +piggott +piggy +pighin +pigniczk +piitz +pilar +pilch +pilcher +pilip +pilipchu +pilkingt +pillars +pillman +pillswor +pilmoor +pilon +pilot +pilote +pilotte +piltz +pim +pimentel +pimisker +pimpare +pimsiree +pinakin +pinalez +pinar +pincas +pinchas +pincheir +pinchen +pincus +pinder +pindur +pineau +pinecres +pineda +pinel +ping +ping-cha +ping-kon +ping-she +pingchar +pingkai +pingshen +pinizzot +pinkerto +pinnegar +pinney +pino +pinren +pinsonne +pintado +pinto-lo +pintwala +piotr +piotto +pip +piper +piperni +piperno +pipit +pipkins +pippa +pippert +pippin +pippo +pipponzi +pippy +piqueras +piraino +pircher +pires +pirkey +pirkle +pirolli +pirooz +piroska +pirzada +pisani +pisheng +piske +pissot +pister +pistilli +pit +pitawas +pitcairn +pitcavag +pitcher +pitre +pitt +pittam +pittges +pittman +pittner +pitton +pitts +pittsbur +pituley +pivert +piwkowsk +pixie +piyasena +piyathad +piyu +piyush +pizzanel +pizzarel +pizzimen +pkdcd +pkg +placido +plaic +plaisanc +plaisant +plambeck +plamondo +planas +planche +plantamu +plante +planthar +planting +plaskie +plasse +plastic +plastina +plater-z +plato +platt +platthy +platts +playatun +please d +plenderl +plett +plevyak +pleydon +plmcoop +ploeg +ploof +plotter +plouffe +plourde +plsntp +plssup +plucinsk +plummer +pluto +plyler +po +po-rong +po-yi +podlesna +podmarof +podolski +poe +poettcke +poff +poh-soon +pohlmann +poincare +poindext +pointner +poirier +poissant +poisson +pojanart +pokinko +pokrifca +pokrywa +pokusay +polak +polakows +polanco +polashoc +polder +poldi +poleretz +poley +poliwoda +polk +polla +pollack +pollard +pollie +pollinzi +pollux +polly +pollyann +pols +polsha +polson +poluchow +polulack +pom +poma +pomerlea +pomeroy +pommainv +pompeo +pomposel +pon +ponamgi +ponthieu +pontus +poobah +pooh +pookie +poole +poon +poorman +popa +popadick +popcorn +popel +popela +popescu +popierai +popoff +popovich +popovics +popowicz +popowycz +popp +poppa +popper +poppy +porebski +porecha +porfirio +porong +port +portelan +porter +porterfi +portia +portie +portigal +porting +portis +portwood +porty +portz +pory +posavad +poseidon +poshiu +pospisil +posta +postavsk +posthumu +postleth +postolek +potamian +potesta +potkonja +potocki +potter +pottle +potts +potvin +pouhyet +poul +poulin +pouliot +poulos +poulsen +poulter +poustchi +powell +power +powers +powlick +pownall +powney +poyer +poyi +poyner +poynting +pozzi +ppaul +prab +prabaddh +prabhaka +prabhu +prabir +prachaya +pracht +prada +pradeep +pradip +pradnyan +prado +pradyumn +praeuner +prafula +pragna +prakash +pramod +prams +pranav +prang +prasad +prasada +prasanna +prashad +prashant +prashaw +pratap +pratapwa +pratibha +pratt +prattico +pravato +praveen +pravin +praxis +praysner +prayson +prchal +precoda +predel +predon +preece +prelims +prem +pren +prent +prentice +prentiss +preo +prescott +presgrov +presley +presner +presotto +pressbur +presson +presti +prestia +prestipi +preston +preston- +prestrud +presutti +preuss +prevatt +preville +previn +prevost +prewitt +pria +pribhu +price +prichard +pricing +prickett +pridgen +priede +priestle +prikkel +primeau +prince +pringle +print +printers +printing +prints +printsup +prinz +priore +pris +prisca +priscell +priscill +prissie +pritchar +prithvi +priti +prity +privett +priviter +priya +probert +problems +probs +procacci +procca +procner +procter +prodmfg +prodmgmt +producti +prof +proffit +prog +program +program- +proj +projects +projofc +prokes +prokop +prokopen +promac +propes +prosise +prosperi +pross +prosyk +prototyp +proudfoo +proulx +provenca +provench +pru +prudence +prudi +prudy +prue +pruett +pruitt +prunier +prupis +prybyla +pryce +prymack +pryor +prystie +pryszlak +przewloc +przybyci +psce +pseudony +psklib +psutka +ptefs +ptolemy +publicat +pubs +puchala +puckett +puddingt +pue-gilc +puelma +puent +puett +puetz +puff +pufpaff +pugas +pugh +puglia +pui-wah +pujara +pulak +pulcher +pulcine +pulitzer +pullan +pullum +pulver +punch +pundyk +puneet +pung +punsalan +puran +purcell +purchasi +purdy +purgerso +puringto +purnam +purnell +purnima +purohit +purposes +purshott +purson +puryear +pushelbe +pusun +pusztai +putman +putnam +putnem +puukila +pye +pyle +pyles +pyng +pyong +pyotr +pyron +python +qadir +qadri +qainfo +qainsp +qi-de +qide +qihan +qiming +qin +qing +qing-hui +qinghui +qingyan +qiuyun +qizhong +qu +quabidur +quality +quan +quane +quang +quang-tr +quante +quantril +quarles +quarterm +quattruc +quayle +queenie +quek +quelch +quennevi +quensett +quent +quentin +querenge +querida +queries +quesnel +questell +quevillo +quigley +quijano +quill +quillan +quilty +quincey +quincy +quinhon +quinlan +quinn +quinones +quint +quinta +quintana +quintero +quintill +quintin +quintina +quinton +quintus +quinz +quite a +quixote +quizmast +quoc +quoc-vu +quocanh +quoi +quon +qureshi +quyen +quynh +raab +raaf +raaflaub +raanan +rab +rabadi +rabaglia +rabatich +rabbi +rabecs +rabenste +rabi +rabiasz +rabie +rabin +rabipour +rabjohn +rabon +rabzel +racette +rachael +rached +rachel +rachele +rachelle +rachmani +racicot +racine +racioppi +racz +rad +radames +radcliff +raddalgo +raddie +raddy +radek +radford +radha +radick +radio +radko +radojici +radomir +radoslav +radovnik +radulovi +radvanyi +rae +raeann +raejean +raf +rafa +rafael +rafaela +rafaelia +rafaelit +rafaelll +rafaello +rafe +rafek +raff +raffaell +raffarty +rafferty +raffi +rafflin +rafi +rafik +rafiq +rafol +rafols +rafter +raftery +ragan +ragbir +ragde +raghav +raghava +raghavan +ragheb +raghu +raghunat +raghuvir +ragland +raglin +ragnar +ragsdale +ragu +ragui +raha +rahal +rahardja +rahdar +rahel +rahimtoo +rahm +rahman +rahmani +rahmany +rahmatal +rahn +rahrer +rahul +raicu +raif +raigwell +raila +railey +raimondo +raimund +raimundo +raina +raine +rainer +raines +rainey +raing +rainmake +rains +rainsfor +raissian +raiswell +raj +raja +rajadasa +rajagopa +rajala +rajan +rajani +rajanika +rajapaks +rajarshi +rajat +rajcher +rajchgod +rajchwal +rajczi +rajeev +rajen +rajendra +rajesh +rajeswar +rajguru +rajinder +rajini +rajiv +raju +rajwani +rakeim +rakel +raker +rakesh +rakhal +rakhuma +rakochy +rakotoma +raleigh +raley +ralf +rali +ralina +ralph +ralston +ram +rama +ramachan +ramadoss +ramage +ramah +ramakant +ramakesa +ramakr +ramakris +ramamoor +raman +ramana +ramanamu +ramanan +ramanand +ramanath +ramani +ramaprak +ramarao +ramaswam +rambo +rambow +ramee +ramesh +ramey +ramez +rami +ramin +ramirez +ramirez- +ramiro +ramiz +ramkisso +ramkumar +ramlogan +ramnarin +ramneek +ramnikla +ramon +ramona +ramonda +ramondt +ramos +rampaul +rampino +ramroop +ramsaran +ramsay +ramsayer +ramsden +ramses +ramsey +ramseyer +ramzi +ramzy +ran +ran-joo +rana +ranahan +ranald +ranbir +rance +rancell +rand +randa +randal +randall +randecke +randee +randel +randell +randene +randhawa +randhir +randi +randie +randolf +randolph +randy +ranea +ranee +ranette +raney +ranga +rangan +ranganad +ranganat +rangasam +rangaswa +rangchen +rangel +ranger +rangooni +rani +rania +ranice +ranieri +ranique +ranjan +ranjit +rank +rankin +ranna +rannells +ranney +ranoa +ranoska +ransell +ransom +ranson +rantala +ranvir +rao +raouf +raoul +raphael +raphaela +rappopor +raquel +raquela +rasberry +raschig +rashed +rashedi +rashid +rashid-a +rashidah +rashidi +rashmi +rasia +rasla +rasmus +rasmusse +rasselas +rassell +rastelli +rastogi +ratcliff +rathbun +rathnaku +ratko +ratnam +ratnayak +rattanap +rattray +ratz +rau +raud +raudres +rauen +raul +rausa +rausch +raven +raves +ravi +ravid +ravinder +ravindra +raviv +ravji +rawley +rawnoi +raxter +ray +rayan +raychel +raye +rayl +rayleigh +rayment +raymona +raymond +raymund +rayna +raynald +raynard +raynell +rayner +raynor +rayshell +raz +razavi +rccl +rch +rchisn +rchlab +rea +read +reade +reader +readling +readme 3 +reagan +reagen +real +realtime +reamonn +rean +reates +reaume +reaves +reavis +reba +rebbecca +rebe +rebeca +rebecca +rebecka +rebeka +rebekah +rebekkah +rebel +rec +recabarr +receivin +rechelle +reckhard +recktenw +records +recsnik +recyclin +red +reda +redbeard +redd +reddick +reddigan +redding +reddingt +reddy +redfoot +redford +redgie +redinbo +redish +redman +redmond +redshaw +redway +ree +reeba +reece +reed +reeder +reena +rees +reese +reeta +reetz +reeva +reeve +reeves +refat +refuerzo +reg +rega +regan +rege +regen +reggi +reggie +reggis +reggy +regier +regimbal +regina +reginald +reginaul +regine +reginia +regis +register +regnier +rego +rehbein +rehder +rehel +reich +reichenb +reiching +reichman +reichow +reid +reidar +reidelbe +reider +reif +reifschn +reijerke +reiko +reilly +reiman +reimann +reimburs +rein +reina +reinald +reinaldo +reinboth +reind +reine +reiner +reinhard +reinhold +reinink +reinke +reinlie +reinman +reinold +reinwald +reis +reiser +reiss +reist +reiter +reitfort +reith +reitling +rejean +rejeanne +reka +rekowski +relation +reller +rem +rembecki +rembish +remedios +remers +remi +remillar +remingto +remitha +remo +remon +remrey +remson +remus +remy +ren +rena +renado +renae +renaldo +renard +renata +renate +renato +renaud +renault +rendell +rendon +rene +rene-ala +reneau +renee +renell +renelle +renema +renette +renfro +renfroe +renganat +renie +renita +renken +renmarie +renner +rennie +rennolds +renny +reno +renoir +renold +renton +renu +renwick +repair +repeta +reportin +reports +requel +requests +research +resende +resnick +ress +ressner +rester +restore +restrepo +results +resve +reta +retallac +retallic +retha +rettie +reube +reuben +reubens +reuss +reuven +reva +revah +revelle +revill +revis +revkah +rewitzer +rex +rexford +rexroad +rey +reyad +reyaud +reydman +reyes +reyna +reynaldo +reynard +reynold +reynolds +reza +rezaian +rezansof +rezneche +reznick +reznik +rezzik +rfa +rfeynman +rff +rhattiga +rhea +rheal +rheault +rheaume +rheba +rheta +rhett +rhetta +rhew +rhiamon +rhianna +rhianon +rhine +rhoades +rhoads +rhoda +rhodeniz +rhodes +rhodia +rhodie +rhodri +rhody +rhona +rhonda +rhough +rhyndres +rhys +rhyu +ri +ria +riad +rialland +riane +riannon +rianon +riaz +ribakovs +ribaldo +ribi +ribordy +ribot +ric +rica +ricard +ricardo +ricca +riccardo +riccitel +ricciuto +rice +rich +richad +richard +richardo +richards +richart +richelle +richer +richey +richie +richlark +richman +richmond +richmoun +richter +richy +rici +rick +rick-jan +rickard +rickborn +rickel +ricker +rickert +ricketso +ricketts +rickey +ricki +rickie +rickjan +rickrd +ricks +ricky +rico +ricoriki +riddall +ridder +riddick +rider +ridge +ridgeway +ridgewel +ridgway +ridha +ridley +riebl +ried +riedel +riehle +riekie +rieko +rieni +rigby +rigdon +rigel +riggins +riggs +riggsbee +righter +rightmir +rigobert +rigsbee +rigstad +rijn +rijos +rijswijk +rik +riki +rikki +rikley +riley +rilla +rima +rimantas +rimey +riml +rimmler +rimsa +rina +rinaldo +rinawi +ring +ringo +rini +rintala +rintel +rintoul +rio +riobard +riopel +riopelle +riordan +rios +riou +rioux +rip +ripa +ripley +risa +risdal +risher +rishy-ma +risko +risler +rist +risto +rita +ritalynn +ritchey +ritchie +ritenour +rittenho +ritter +rittmann +ritz +ritza +ritzmann +riva +rivaherr +rivalee +rivard +rivera +rivers +rivest +rivi +rivkah +rivy +rix +riyad +riyaz +rizal +rizewisk +rizk +rizky +rizwan +rizzardi +rizzo +rizzuti +rk +rnashcro +ro +roana +roanna +roanne +roarke +rob +robann +robart +robb +robbert +robbi +robbie +robbin +robbins +robby +robbyn +robeling +robena +robenia +roberge +robers +roberson +robert +roberta +roberto +roberts +robertso +robieux +robillar +robin +robina +robinet +robinett +robinia +robins +robinson +robinwil +robitail +robles +robling +robney +robson +robustne +roby +robyn +rocco +roch +roche +rochell +rochella +rochelle +rocheste +rochette +rochon +rocio +rock +rocke +rockey +rockford +rockie +rockley +rockly +rockwell +rocky +rod +roda +rodd +roddick +roddie +roddy +rodely +roden +rodenfel +rodenhui +rodent +roderic +roderich +roderick +roderigo +rodge +rodger +rodgers +rodgin +rodi +rodie +rodina +rodkey +rodney +rodolfo +rodolph +rodolphe +rodrick +rodrigo +rodrigue +rodrigus +rodrique +rodschat +roe +roebling +roedel +roehl +roehrig +roel +roelof +roelofs +roemer +roerick +roesler +roeten +rog +rogan +rogelio +roger +rogerio +rogers +roget.wo +rogge +rogne +rogness +rognlie +rogoff +rogue +rohal +rohan +rohe +rohit +rohtert +roi +roieh +roig +rois +roithmai +roj +rojas +rojer +rok +rokas +roland +rolande +rolando +roldan +roleson +roley +rolf +rolfe +rolfes +rolland +rollie +rollin +rollins +rollinso +rollo +rolls +rolly +rolnick +rolph +rolston +roly +rolyn +roma +romagnin +romain +roman +romanchu +romani +romano +romanows +rombeek +romberg +rombough +romeo +romero +romi +romina +rommel +rommell +romola +romolo +romona +romonda +romulus +romy +ron +rona +ronaald +ronak +ronald +ronalda +ronaldo +ronalds +ronaldso +ronan +ronda +rondeau +ronen +ronendra +roney +rong +rong-che +rong-chi +rong-jen +rong-jwy +rongchei +ronghui +rongjen +rongjwyn +roni-jea +ronica +ronitt +ronkus +ronn +ronna +ronneke +ronni +ronnica +ronnie +ronny +roob +roobbie +roohy-la +rooney +roosevel +root +roots +roozbeh +roper +roque +rora +rori +rorie +rorke +rory +ros +rosa +rosabel +rosabell +rosado +rosaleen +rosalia +rosalie +rosalind +rosaline +rosalyn +rosalynd +rosamond +rosamund +rosana +rosanna +rosanne +rosario +rosch +rosche +rosco +roscoe +rose +roseann +roseanna +roseanne +rosebud +roseland +roselia +roselin +roseline +rosella +roselle +rosemari +rosemary +rosemond +rosen +rosenbau +rosenber +rosenblu +rosendal +rosene +rosenfel +rosenqui +rosentha +roser +rosetta +rosette +rosewell +rosey +roshelle +rosie +rosien +rosina +rosita +roski +rosko +roslyn +rosmunda +rospars +ross +ross-ada +ross-ros +rossanes +rosser +rossi +rossie +rossigno +rossingt +rosson +rossy +rosvick +rosy +roszko +rotenber +roth +rothamel +rothey +rothwell +rotondo +rotzjean +rouer +rouhad +rouleau +roulez +roundy +roupen +rourk +rourke +rous +rousseau +rousset +roussier +roussin +roussy +routhier +routing +rouvin +row +rowan +rowatt +rowe +rowell +rowen +rowena +rowhani +rowland +rowlands +rowley +rowney +rowsell +roxana +roxane +roxanna +roxanne +roxi +roxie +roxine +roxy +roy +roya +royal +royall +royals +royce +roychowd +royden +royer +royle +royster +roz +rozaini +rozalia +rozalie +rozalin +rozamond +rozanna +rozanne +roze +rozele +rozella +rozelle +rozen +rozett +rozier +rozin +rozina +rozon +rozumna +rriocard +rtingres +rtp +rtpbuild +rtprel +rtprelb +ru +ruane +ruaud +ruban +rubanovi +rube +ruben +rubens +rubenste +rubetta +rubi +rubia +rubie +rubin +rubina +rubinfel +rubinov +rubinste +rubio +ruby +ruchel +ruchi +ruck +ruckman +rud +rudd +ruddell +ruddick +ruddie +ruddle +ruddy +rudell +rudi +rudiak +rudich +rudie +rudiger +rudin +rudis +rudisill +rudolf +rudolfo +rudolph +rudy +rudyard +rudzinsk +rudzitis +rueben +ruecha +ruediger +ruel +ruest +ruetz +ruey +rufe +ruffolo +rufino +rufus +rugg +ruggiero +rui +rui-tao +rui-yuan +ruigrok +ruitao +ruiz +rumley +rummans +rummel +rummell +runciman +rundle +rundstei +rungroj +runkel +runnels +running +runyon +ruoh-chy +ruohchyu +rupa +rupert +ruperta +ruperto +rupnow +rupp +ruppert +ruprecht +ruqiang +rurick +rurik +rusch +ruschmei +rushing +rushmore +rushton +rusin +ruspini +russ +russel +russell +russett +rustie +rustin +rustu +rusty +rutger +ruth +ruthann +ruthanne +ruthart +ruthe +rutherfo +ruthi +ruthie +ruthy +rutland +rutledge +rutt +ruttan +rutter +ruttger +rutulis +rutyna +ruud +ruy +ruyant +ruyle +ruzicka +ruzycki +ryall +ryals +ryan +ryann +rybczyns +rycca +ryce +rychlick +ryde +ryder +rydhan +ryerson +rygwalsk +rykwalde +ryley +rylott +ryman +rymkiewi +rynders +rynties +ryohei +ryon +ryoung +ryszard +ryun +rzepczyn +sa'id +saad +saake +saal +saatciog +saavedra +saba +sabadash +sabah +sabanaya +sabat +sabatell +sabatini +sabatino +sabbagh +saber +saberi +sabety +sabiha +sabin +sabina +sabine +sabo +sabol +sabooria +sabourin +sabra +sabri +sabrina +sabry +sabuson +sabzali +sacarell +sacchett +sacha +sachidul +sachiko +sachindr +sachs +sacks +sacto +sada +sadan +sadath +sadegh +sadeghi +sadella +sadie +sadler +sadorra +sadowska +sadoyama +sadri +sadroudi +sadru +sadye +saed +saeed +saeid +safaa +safah +safinia +sagan +sage +sager +sagers +sagris +saha +sahay +sahib +sahinalp +sahli +saibal +saibun +said +saidee +saidzade +saied +saifalla +saifulla +saify +saiid +saikaley +sails +saini +sainsbur +saisho +sait +saito +saitoh +saiyed +sakaguch +sakai +sakamaki +sakauye +sakus +sal +salada +saladna +salah +salaidh +salam +salamon +salapek +salazar +salb +salcudea +saldanha +saleem +saleh +salehi +salem +salembie +salemi +sales +salgado +salhany +salibi +salim +salim-ya +salimi +salina +salinas +salis +salkilld +salkini +salkok +salladay +salle +sallee +sallehud +salli +sallie +sally +sallyann +salmon +saloma +salome +salomi +salomo +salomon +salomone +salsbery +saltamar +salter +saltside +salva +salvador +salvato +salvator +salvidor +salvin +salwa +salyer +salyniuk +salzillo +sam +samac +samalot +saman +samantha +samara +samaratu +samaria +samaroo +sambar +sambi +sambo +sameh +samhaber +sami +samia +samieian +samir +sammie +sammon +sammons +sammy +samora +sampalea +sampat +sampath +samples +sampson +sampson- +samshixu +samson +samsonen +samual +samuel +samuele +samy +sanaa +sanabria +sanae +sanand +sanborn +sanche +sanchez +sancho +sanda +sandberg +sandburg +sande +sandeep +sandell +sander +sanders +sanderso +sandford +sandhar +sandhu +sandhya +sandi +sandie +sandifor +sandip +sandison +sandiway +sandler +sandlfor +sandner +sandness +sandor +sandra +sandre +sandrine +sandro +sandrock +sandy +sandye +sanford +sanford- +sang +sang-mau +sang-woo +sangbong +sangha +sanghami +sangho +sangiova +sangman +sangwook +sanh +sanity +sanja +sanjay +sanjeet +sanjeev +sanjib +sanjiv +sanjiva +sanjoy +sankey +sanks +sanoy +sanramon +sanschag +sansom +sanson +sansone +santa +santabar +santella +santi +santiago +santiest +santitor +santo +santos +santosh +sanzone +sapena +saphir +sapphira +sapphire +saqib +sara +sara-ann +saraann +sarah +sarajane +saran +saran-br +sarangar +sarasina +sarath +saravano +sarawath +sarbutt +saree +sarena +sarene +sarette +sarge +sargent +sargeson +sari +sarin +sarina +sarine +sarioglu +sarita +sarkari +sarlos +sarma +sarna +saroj +sarracin +sarrasin +sarrazin +sarsh +sarson +sartin +sartiran +sarto +sartor +sarubbi +sasaki +sascha +sasha +sashenka +sashi +sasinows +sask +saskia +sasore +sassan +sassine +sasson +sastry +saswata +sathe +sati +satin +satis +satish +satkamp +satkunas +sato +satoh +satoshi +satta +sattar +satterfi +sattler +satya +satyajit +satyanar +saucerma +sauck +sauder +saudra +sauer +saul +saulnier +sauls +saumitra +saumure +saumya +sauncho +saunder +saunderc +saunders +saundra +saungika +sauprobo +sauvagea +sauve +sauveur +savadkou +savanh +savard +savarimu +savaryeg +savina +savino +savita +savo +savoie +savoj +savoula +saw +sawada +saward +sawaya +sawchuk +sawczyn +sawita +sawsan +sawyer +sawyere +sawyers +sax +saxe +saxena +saxon +say +sayar +sayed +sayeeda +sayegh +sayer +sayers +sayla +sayre +sayres +scalabri +scalera +scales +scammerh +scamurra +scandret +scanga +scanlan +scanlon +scapin +scarboro +scarbrou +scarface +scarffe +scarlet +scarlett +scarrow +scatena +scates +schaap +schacham +schachtl +schack +schadan +schaefer +schafer +schaffel +schaffer +schallen +schaller +schallio +schanck +schank +schanne +scharf +schartma +schatzbe +schauer +schavo +schavone +schechtm +scheck +scheckle +schecter +schedule +scheduli +scheer +scheffle +scheible +scheidt +scheifel +schejbal +schell +schellen +schembri +schemena +schenck +schendel +schenk +schenkel +schepps +scherbin +scherer +schermer +scherzin +schesvol +scheuerm +schick +schieber +schiefer +schiegl +schierba +schill +schiller +schillin +schiltz +schinkel +schipper +schireso +schirmer +schirtzi +schissel +schittl +schlacht +schlagen +schlange +schledwi +schlegel +schlemme +schlicht +schloboh +schluter +schmadtk +schmeder +schmeing +schmeler +schmelze +schmidt +schmitig +schmitt +schmitz +schmoe +schnacke +schnaith +schneide +schnell +schnirer +schnob +schnupp +schnurma +schober +schoch +schoen +schoener +schoenfe +schoenin +schoenli +schoettl +schofiel +scholes +scholey +scholman +scholtz +schonber +schooley +schopenh +schousbo +schouwen +schrader +schrag +schrage +schram +schraner +schrang +schreibe +schreier +schreife +schreine +schrier +schroede +schroer +schroff +schruefe +schrupp +schrybur +schubert +schuck +schucker +schuddeb +schuett +schuette +schuld +schulte +schultz +schultze +schulz +schulze +schumach +schumann +schuster +schute +schutte +schutz +schuyler +schvan +schwab +schwader +schwalba +schwane +schwante +schwartz +schwarz +schwenk +schyndel +schyving +scibek +scif +scissons +scodras +scomello +sconzo +scooter +scorpio +scorziel +scot +scott +scotti +scottie +scottjop +scottt +scotty +scournea +scovell +scovill +scp +scpbuild +scpiivo +scptest +scrantom +scrbacic +screener +scribner +scrivens +scroger +scss +scssdev +scully +scurlock +scythia +se +seabrook +seager +seagle +seagrave +seagrove +seahawk +seale +sealy +seamster +seamus +sean +seana +seang +seanna +seany +searl +searle +searles +sears +seatter +seawell +seay +sebastia +sebastie +sebata +sebeh +sechang +sechen +sechrest +secrest +security +seda +sedat +sedayao +seddigh +seddon +sedigheh +sedovic +sedran +see +seeds +seegobin +seelaend +seelan +seeler +seeley +seema +seenu +seery +sees +seethara +segal +segars +seggie +seguin +sehat +sehgal +sehinson +sehmbey +sehyo +sei +seidel +seiden +seidl +seidman +seifers +seifert +seifried +seiji +seiko +seiler +seiple +seipue +seitz +seiz +sej +sek-ming +seka +sekar +sekhar +seki +sekiguch +sekming +sekuler +sela +selbrede +selby +selchow +selcuk +seldon +selena +selene +selent +selestin +selia +selic +selie +selig +selim +selime +selina +selinda +seline +seliske +selisker +selkirk +sella +sellars +selle +sellers +sellgren +sellis +sells +sellwood +selma +selva +selvaraj +selwyn +sembi +semeniuk +semenzat +semerau +semler +semmens +semmler +semoon +sena +senad +senderow +sendyk +senecal +senese +sengoba +sengupta +seniuk +senng +senser +senten +sentner +sentovic +senyildi +senyshyn +seob +seoju +seok +seong +seoul +sepe +sepesi +sephira +sepko +serack +serafin +seraphin +serapin +serazzi +serban +serber +serbin +serbus +serdar +serduke +seregely +serena +serene +serethia +serge +sergeant +sergei +sergent +sergey +sergi +sergio +sergiu +sergo +seroka +serour +serraf +serrano +serre +servais +servance +services +servidio +serville +seshadri +seshan +seth +sethi +sethian +setiawan +seto +setsuko +settels +setterfi +settles +seufert +seumas +seung +seungbin +seungchu +seungjun +seuss +seven +severin +severina +severn +severns +sevigny +sevilla +seville +seward +sewell +sey-ping +seyar +seyed +seyfolla +seyma +seymour +sezer +sfiroudi +sforza +sgorniko +sguigna +sha-wen +shabatur +shabbir +shabo +shacham +shackelf +shacklef +shacklet +shackley +shaddock +shadow +shae +shafer +shaffer +shafik +shafiq +shafique +shahab +shahan +shahani +shahen +shahid +shahram +shahriar +shahrin +shahrokh +shahrood +shai +shaib +shaibal +shaida +shaila +shailan +shailen +shailend +shailesh +shailin +shaina +shaine +shaji +shaker +shakeri +shakib +shakil +shakoor +shakor +shalizi +shalla +shalmon +shalna +shalne +shalom +shama +shamblin +shames +shamim +shamir +shamji +shams +shamshad +shamshir +shamsia +shamus +shan +shan-min +shan-pin +shana +shanahan +shanan +shanda +shandee +shandeig +shandie +shandra +shandy +shane +shaner +shang +shang-ti +shangi +shangtia +shani +shanie +shankar +shanlin +shanmin +shanna +shannah +shannan +shannen +shannon +shanon +shanping +shanta +shantee +shanti +shantz +shao +shao-she +shaoshen +shapcott +shapin +shapiro +shapland +shappir +shara +sharad +sharada +sharae +sharai +sharan +sharee +shari +sharia +sharif +shariff +sharissa +sharity +sharkey +sharky +sharl +sharla +sharleen +sharlene +sharline +sharma +sharman +sharmila +sharnoff +sharon +sharona +sharone +sharpe +sharratt +sharri +sharron +sharry +shary +sharyl +sharyn +shashank +shashi +shastri +shastry +shatter +shattuck +shau +shaughan +shaughn +shaughne +shaukat +shaumil +shaun +shauna +shaupoh +shaver +shaw +shaw-yun +shawen +shawn +shawna +shawnee +shawyune +shay +shay-pin +shayanpo +shayla +shaylah +shaylyn +shaylynn +shayna +shayne +shayping +shea +sheaffer +shealy +shean +sheara +shearer +shearin +shearman +shears +sheba +shebanow +shechtma +shedd +shedman +sheehan +sheela +sheelagh +sheelah +sheena +sheeree +sheergar +sheets +sheff +sheffey +sheffie +sheffiel +sheffy +sheidafa +sheikh +sheikna +sheila +sheila-k +sheilah +sheilaka +sheileag +shein +shek +shekar +shekhar +shekwan +shel +shela +shelagh +shelba +shelbi +shelby +shelden +sheldon +shelegey +shelia +shell +shelley +shelli +shellie +shellin +shellman +shelly +shellysh +shelton +shem +shemwell +shen +shen-zhi +shena +sheng +sheng-fu +shengfu +shengru +shengwen +shengwu +shennan +shep +shepard +shepherd +sheppard +shepperd +sher +sherali +sherard +sheraton +sherban +shere +sheree +sheremet +sheri +sheri-ly +sheridan +sherie +sherif +sherill +sherilyn +sherin +sherk +sherline +sherlock +sherm +sherman +shermie +shermy +sherona +sherow +sherra +sherrard +sherrel +sherrell +sherrer +sherri +sherrie +sherrill +sherrily +sherry +sherrye +sherryl +sherwan +sherwin +sherwood +sherwyn +sherwynd +sherye +sheryl +sheth +sheu +sheung +shew +shewchen +shi +shi-qin +shi-wei +shiang-y +shiangyi +shiao-mi +shiaomin +shibahar +shibata +shibberu +shibo +shieff +shieh +shiel +shiela +shields +shiell +shier +shiffer +shiflett +shigeaki +shigeki +shigemur +shigenao +shigeo +shigeru +shih +shih-dar +shih-hai +shih-hsi +shih-kua +shih-tie +shihhai +shihhsiu +shihkuan +shihtien +shik +shikui +shila +shiley +shilla +shilling +shimada +shimandl +shimiz +shimizu +shimshon +shin-dug +shina +shinder +shindug +shing +shing-ch +shing-mi +shingche +shingler +shingmin +shinichi +shinji +shinjo +shinobu +shinohar +shinzo +shiou +shipe +shipp +shippen +shiqin +shiquan +shir +shirai +shiranth +shiratsu +shiraz +shireen +shireman +shirene +shirey +shirin +shirinlo +shirish +shirl +shirlee +shirleen +shirlene +shirley +shirley- +shirline +shirman +shiroshi +shirriff +shishakl +shishido +shiu +shiu-lin +shiuan +shiue +shiuling +shiun +shiung +shiv +shiva +shivaji +shivapra +shivchar +shivdars +shivnan +shiwei +shixian +shlomo +shmoys +shnay +shnider +sho +shoaf +shobana +shockley +shoeb +shoemake +shoens +shoji +sholom +shon +shona +shonda +shonka +shonuck +shoou-yu +shoouyu +shop +shoppel +shorgan +shorwan +shoshana +shoshann +shostak +shou +shou-che +shou-mei +shoucher +shoulars +shouli +shoun +shouresh +showers +shreve +shriberg +shrieves +shripad +shriram +shtivelm +shtulman +shu +shu-chen +shu-gong +shu-mei +shuang +shuangli +shubaly +shuchen +shue +shuechia +shuen +shugong +shuichi +shuji +shukor +shukster +shuler +shull +shultz +shum +shuman +shumate +shumei +shunfeng +shung +shunhui +shunmuga +shunro +shuo +shupe +shuqing +shurlock +shurtlef +shurwood +shuster +shusuke +shute +shutler +shutoku +shutterb +shuvra +shuyen +shwed +shwu-chy +shwuchyn +shya-yun +shyam +shyan +shyh-chi +shyhchin +shylo +shyoko +shypski +shyu +si +siamack +siamak +siana +sianna +siaw +sib +sibbet +sibbie +sibby +sibeal +sibel +sibella +sibelle +sibiga +sibilla +sibincic +sibley +sibyl +sibylla +sibylle +sicard +sich +sichao +sickle +sickler +sicotte +sid +siddall +siddell +siddiqui +sides +sidhu +sidnee +sidney +sidone +sidoney +sidonia +sidonnie +sidor +sidorovs +sieben +sieber +siefert +siegal +siegel +siegfrie +siegle +siegmund +siegurd +siehl +sieling +siemens +siemer +sienggo +siew +siew-kia +siewert +sifer +siffre +sig +sigda +sigfrid +sigfried +siggy +sigismon +sigismun +sigmon +sigmund +signe +sigrid +sigurd +sigurdso +sigut +sigvard +siham +sihem +sik-yin +sika +sikander +sikes +sikita +sikri +sil +silang +silas +silburt +sile +sileas +silgardo +silianu +silieff +silins +sills +sils +silva +silvain +silvan +silvana +silvano +silvanus +silverma +silverst +silverth +silveste +silvestr +silvia +silvie +silvio +silwer +sim +simaan +simanski +simard +simard-n +simcha +simcoe +simcox +sime +simen +simeon +simeone +simhan +simion +simkin +simler +simmonds +simmons +simms +simon +simon-ch +simon-pu +simona +simone +simonett +simonian +simonne +simonovi +simons +simonsen +simpkin +simpson +simren +sims +simser +simson +simulati +simzer +sina +sinanan +sinasac +sinchak +sinchau +sinclair +sinclare +sindee +sing +sing-pin +singbeil +singer +singh +singhal +singham +singires +singyu +sinh +sinha +sinkfiel +sinkovit +sinnett +sinnott +sinoyann +sinyor +siobhan +siomalas +siotong +sioux +siouxie +siperco +sipes +siping +sipple +sir +sirevici +siripong +sirojith +sisely +sisile +sisk +siso +sissela +sissie +sissy +sist +sitar +sitch +sitler +siu +siu-kwok +siu-ling +siu-man +siusan +siv +siva +sivaji +sivasoth +siward +sizto +sj +sjaak +sjerps +sjouke +skaff +skaftaso +skaggs +skalski +skanes +skaret +skariex +skedelsk +skeeter +skef +skell +skelly +skene +skeoch +skerlak +skerry +skeuse +skiba +skiclub +skillen +skillman +skinner +skip +skipp +skipper +skippie +skippy +skipton +sklower +skoberne +skof +skopliak +skrebels +skriverv +skrobans +skrobeck +skruber +skuce +skuratow +skwarok +sky +skye +skylar +skyler +slaa +slabaugh +slaby +slade +sladek +slagel +slartiba +slatteng +slattery +slautter +slavin +sldisk +sleeman +sleeth +slempers +slick +slinkard +slinowsk +sliter +sloan +sloane +slobin +sloboda +slobodia +slobodri +slonosky +slotnick +slozil +sluis +slunder +slusser +sly +slyteris +smale +smalltal +smallwoo +smecca +smedema +smeenk +smelters +smerdell +smerek +smid +smine +smit +smita +smith +smithdea +smithson +smits +smitty +smolin +smook +smoot +smothers +smrke +smrke-su +smuda +smulders +smyrl +smyth +smythe +snair +snapper +snarr +snead +snedden +snedeker +snehal +snelgrov +snelling +snider +sniderma +snipes +snips +snodgras +snoke +snorri +snowden +snuggs +snyder +soard +sobchuk +sobczak +sobeck +sobel +sobiesia +sobitha +sobkow +sobolak +sobolews +sobon +sochovka +socorrit +socrates +sodano +soderber +sodhi +soebowo +soegiono +sofeya +soffa +sofia +sofie +sofoklis +soh +sohaib +sohail +sohal +sohale +sohayla +sohier +sohni +sohns +sohota +soiffer +soin +sojka +sojkowsk +sokolows +sokyrko +sol +solai +solange +soldera +solheim +soliman +solita +solkoff +sollee +sollie +sollman +solly +solman +solodko +solomon +solovay +solski +soman +somani +somenzi +somera +somers +somerset +somervil +somisett +sommer +sommerdo +sommerfe +sompong +somppi +somsak +son +sonbol +sondra +sondueim +song-cha +song-ho +songchar +songho +songnian +sonhing +sonia +sonier +sonja +sonne +sonni +sonnie +sonnnie +sonny +sono +sonoda +sonoe +sonya +soo +sood +soohong +soohoo +sook +sookdeo +sooley +soong +soonhoi +sophey +sophi +sophia +sophie +sophroni +sorathia +sorbi +sorcha +soreanu +soren +sorensen +soriano +sorin +sorkin +soroker +sorrel +sorrell +sorrenti +sos +sosa +sosanna +sossaman +sotelo +sotiriad +sotiris +soto +sotos +souba +soucie +soucy +sougata +souheil +soulef +soules +soullier +soumis +soumitra +sounya +souphala +sources +souren +sourin +sourisse +sourour +sousa +soussa +southard +souther +southon +southwor +souza +sova +soweidan +sowry +soyeh +soyland +soyong +soyster +soyuer +space +spallin +spann +spannbau +sparacio +sparkes +sparks +sparksma +sparky +spass +spaugh +speakec +speaker +spearman +spearpoi +spears +specs +speedy +speer +speers +speight +spejewsk +spence +spencer +spense +spenser +sperman +spessot +spicer +spickelm +spieker +spieler +spight +spike +spilchak +spillane +spily +spindler +spinelli +spingola +spinks +spirakis +spirkovs +spiros +spisak +spitzer +spivey +splitt +spohn +spolar +sponagle +sponchia +spooner +spragg +spraggin +sprague +sprandel +sprayber +spriggs +spriging +springth +sprott +sproul +sproule +sprouse +spruell +sprules +sprunger +spudboy +spurlin +spurlock +spurway +spy +spyridon +spyros +squires +squizzat +sr +srawan +src +sreedhar +sri +sridaran +sridevi +sridhar +sridhara +srihari +srikanth +srikrish +srimurti +srinath +srini +sriniuas +srinivas +sriram +sriranja +srirupa +sriv +srivasta +srivatsa +sroczyns +ssi +ssington +st +st-amour +st-denis +st-louis +st-marti +st-onge +st-pierr +st. +st.clair +st.denis +st.germa +st.jacqu +st.john +st.laure +st.louis +st.pierr +st.vil +st_james +staats +stabilit +stace +stacee +stacey +stach +stachowi +staci +stacia +stacie +stackpol +stacy +stadelme +stadler +stafani +staffard +staffeld +staffing +stafford +staford +stagger +staggs +stagmier +stahl +stahly +stainbac +staley +stalin +stallabr +stallcup +stalling +stalter +stamboul +stampfl +stampley +stamps +stan +stanciu +stanczyk +standard +standel +standen +standfor +standrin +stanfiel +stanford +stange +stanisla +stanke +stanleig +stanley +stanly +stansber +stansbur +stansby +stansell +stansfie +stanton +stanulis +stanwood +stapenho +staples +star +starbuck +staring +starkaus +starkeba +starkes +starks +starla +starlene +starlet +starlin +starnes +starowic +starr +stars +starsdps +starzman +stasaski +stasiak +stasney +stastny +stasyszy +stat +staten +stateson +statile +statisti +staton +stavro +stavros +stayton +stclair +stctest +stds +ste +ste-mari +stearn +stearne +stearns +stebbing +steckley +steede +steele +steelman +steen +steenbur +steene +steeves +stefa +stefan +stefana +stefanac +stefania +stefanie +stefano +steffane +steffen +steffens +steffes +steffey +steffi +steffie +steffy +stegall +steggall +stegman +stegmuel +stehen +stehr +steidel +steiert +steinar +steinbac +steinber +steinhar +steip +stejskal +steklasa +stelcner +stella +stellita +stellwag +stemmler +stender +stennett +stenson +stensrud +stepchuk +steph +stepha +stephan +stephana +stephane +stephani +stephann +stephans +stephanu +stephany +stephe +stephen +stepheni +stephens +stephi +stephie +stephine +stepler +stepp +steranka +stercyk +sterczyk +sterescu +stergios +sterian +sterling +stern +sterne +stesha +steski +stetner +stetson +stetter +stettner +stevan +stevana +stevanov +steve +steven +stevena +stevens +stevenso +stevie +stevy +stew +steward +stewart +stewart- +sticklan +sticpewi +stiglitz +stiles +stillman +stillwel +stimler +stina +sting +stinky +stinson +stinzian +stirling +stirrett +stites +stjohn +stobaugh +stock +stocker +stocks +stockton +stockwel +stodart +stoddard +stoelzle +stoevsky +stoffels +stoker +stokes +stokker +stokoe +stokoski +stolzle +ston +stone +stonebra +stonehou +stoner +stonos +stooke +storace +storelli +storey +storm +stormi +stormie +stormy +storrie +story +stotts +stotz +stouder +stough +stovall +stover +stowe +stoyles +strachan +strackho +strader +straka +strandbe +strandlu +strannem +strasser +stratfor +stratton +straub +strauch +strauss +strautma +strawczy +strayhor +streater +streatfi +streibel +streight +streng +strickla +strider +strober +strock +stroemer +strohmey +strom +stronski +stropp +stroud +stroupe +strube +struble +strucche +strudwic +struzyns +stu +stuart +stubblef +stubbs +stuckey +stude +student +studer +stults +stumpf +sturdiva +sture +sturrock +stutts +su +su-xin +suany +suarez +suat +subasing +subhash +subhashi +subhi +subhra +subhrans +subi +subick +subissat +subitha +submital +subodh +subra +subraman +subroto +subsara +subu +sucha +suchitra +suchocki +suda +sudabeh +sudan +sudbey +sudbury +suddarth +sudeep +sudesh +sudha +sudhakar +sudhir +sudip +sue +sue-joe +sue-may +sueanne +suejoe +sueling +suellen +suen +suer +suess +sufcak +suffern +sugandi +sugarbro +sugarman +sugih +sugihara +suh +suha +suhail +suharly +suhas +suilin +suiping +suitt +sujay +suk-ho +suk-jae +suk-yin +sukey +sukhendu +sukho +sukhwant +suki +sukjae +sukku +sukumar +sula +sulatyck +sule +sulewski +suliguin +sullivan +sully +sultan +suman +sumanth +sumaryan +sume +sumi +sumit +sumitro +summach +summerli +summers +sumner +sumpter +sunatori +sundar +sundaram +sundares +sunderla +sundra +sung +sung-cho +sung-sup +sungchin +sungchon +sunghae +sungki +sungkyoo +sungsup +sunil +sunjay +sunnie +sunning +sunny +sunshine +sunstrum +suomela +supervis +support +suprick +supriya +supriyo +sura +surazski +surber +surendra +suresh +suria +surinder +surowani +surray +surreau +surridge +sursal +survey +surya +susan +susana +susanett +susann +susanna +susannah +susanne +susanto +susette +susi +susick +susie +susil +sussie +susumu +susy +sutardja +sutarja +sutarwal +sutcliff +suter +sutera +sutherla +suthers +sutija +sutphen +sutter +sutterfi +sutterli +sutton +suu +suvanee +suwala +suwanawo +suwandi +suxin +suyama +suykens +suyog +suzan +suzane +suzann +suzanna +suzanne +suzette +suzi +suzie +suzuki +suzy +svalesen +svante +sven +svend +svenn-er +svensson +sventek +svetlana +svilans +svm-bnrm +svo +svr +swact +swaden +swails +swaine +swamy +swandi +swann +swanson +swanston +swaranji +swartz +swazey +swd +swearing +swee-joo +sweeney +sweetnam +swen +swenberg +swensen +swenson +swepston +swiat +swiatkow +swick +swidersk +swinamer +swinburn +swinkels +swinks +swinney +swinson +swinwood +swisher +switchin +switzer +swope +swr +swyer +sy +syal +syamala +sybil +sybila +sybilla +sybille +sybyl +sycha +syd +sydel +sydelle +sydeman +sydney +sydnor +sydor +sydoryk +syed +sykes +syl +sylas +sylva +sylvain +sylvan +sylveste +sylvestr +sylvia +sylvie +sylvio +sym +syman +symen +symon +symons +syndra +synful +synness +syposz +syres +syrett +sys +sysadmin +sysint +syssuppo +systems +systest +syun +syyed +szabo +szamosi +szaplonc +szaran +szeto +sziladi +szkarlat +szopinsk +szot +szpakows +szpilfog +sztein +szuminsk +szura +szymansk +szypulsk +ta +ta-sung +tab +tabaja +tabalba +tabatha +tabb +tabbatha +tabbert +tabbi +tabbie +tabbitha +tabby +taber +tabina +tabitha +tabl +tabler +tables +tabor +tac +tachih +tacitus +tad +tadayuki +tadd +taddeo +taddeusz +taddio +tadeas +tadeo +tades +tadeusz +tadevich +tadge +tadio +tadlock +tae +tae-ho +tae-hwan +taeho +taehwan +taffy +taggart +taghizad +tague +tahamont +tahani +taharudd +taheri +tahir +tahsin +tai +tai-jen +tailinh +tailor +taina +tait +taite +taiwana +tajbakhs +tak +tak-wai +takagi +takahash +takahisa +takako +takao +takashi +takashim +takata +takayuki +takefman +takehiko +takeo +takeshim +takeuchi +taki +takis +takiyana +tal +talbert +talbot +talbott +talcott +talevi +talia +talis +tallett +tallia +tallie +tallou +tallulah +tally +talmont +talmy +talton +talya +talyah +tam +tamar +tamara +tamarah +tamarell +tamaresi +tamarra +tamas +tamasi +tamer +tamera +tami +tamiko +tamir +tamma +tammara +tammaro +tammi +tammie +tammy +tamqrah +tamra +tamrazia +tamura +tan +tan-atic +tana +tanaka +tanchak +tancordo +tandberg +tandi +tandie +tandiono +tandiwe +tandy +tanferna +tanglao +tangren +tanhya +tani +tania +tanio +tanir +tanitans +tanja +tann +tanner +tanney +tannie +tanniere +tanny +tansy +tanya +tao +tap +tapani +tape +tapner +tapp +tappende +tappert +tapsell +tara +tarah +tarak +taralp +taraneh +tarant +taranto +taraschu +tarasewi +tardif +tardiff +tardioli +tarek +taren +tareq +targosky +tarik +tariq +tarlamis +tarle +tarmi +tarnai +tarng +taro +tarof +tarquini +tarra +tarrah +tarrance +tarrant +tarsky +tarte +tarus +tarver +taryn +taryna +tas +taschere +tash +tasha +tasia +taskforc +taspatch +tassi +tasso +tassy +tasuk +tasung +tat +tata +tatangsu +tatar +tate +tatemich +tates +tateyama +tatiana +tatiania +tats +tatsdocn +tatsman +tatsugaw +tatsuya +tattenba +tatum +tatyana +tauberg +taul +tauna +taurus +tauscher +tauvia +tavana +tavares +taverner +tavis +tawauna +tawfik +tawnya +tawsha +taxashi +tay +tayeb +tayfun +taylor +taylor-h +tayyab +tc +tchangid +tchir +td +tdr +te-chih +te-hsiu +te-wei +teacher +teador +teague +team +teasley +tebbe +tebinka +tec +tech +techih +technica +technoso +teck +tecklenb +ted +tedd +tedda +teddi +teddie +teddy +teder +tedi +tedie +tedman +tedmund +tedra +tedrick +teed +teena +teerdhal +teetwo +teh +tehchi +tehsiu +teichman +teiichi +tein-min +teinmin +teirtza +teitelba +tej +tejada +tejal +tejani +tel +telco +tele +telecom +telesis +telex +telfer +telidis +telke +tello +tellup +telos +telva +temp +temple +temple-d +templeto +ten-huei +tena +tencer +teng +tenhuei +teniola +tenna +tennant +tenney +tennyson +teo +teodoor +teodor +teodora +teodoric +teodoro +tera +terakado +teran +terangue +terence +terencio +teresa +terese +teresina +teresita +teressa +terez +teri +teriann +terminal +terneus +terr +terra +terrade +terrance +terranel +terranov +terrel +terrell +terrence +terresa +terri +terri-jo +terri-le +terrie +terrijo +terrilei +terrill +terry +terrye +terryl +tersina +teruko +teruo +teruyuki +terwey +terwilli +terza +terzian +tesa +tesch +tesfagab +tesfamar +tesh +tesla +tess +tessa +tessi +tessie +tessier +tessler +tessty +tessy +testa +testagc +tester +testing +testingp +testntmv +testsds +testtool +tetrault +tetreaul +tetsukaz +tetsumo +tetsuo +tetsuya +tetsuyuk +teufel +tevlin +tewksbur +tex +teymour +thabet +thac +thach +thacher +thacker +thad +thaddeus +thaddus +thadeus +thai +thain +thaine +thais +thaker +thakor +thakur +thaler +thalia +tham +thames +thane +thang +thanh +thanh-ha +thanh-ho +thanh-hu +thanh-qu +thanh-so +thanh-ti +thanos +thao +tharby +tharring +thatch +thatcher +thatte +thaxter +thaxton +thayer +thayne +the +the worl +thea +theadora +thebault +theda +thedford +thedora +thedric +thedrick +thege +theis +thekla +thelma +theloose +themann +theo +theobald +theochar +theodor +theodora +theodore +theodori +theodosi +theofani +theohari +theologo +theoret +thera +theresa +therese +theresin +theresit +theressa +theriaul +therien +therine +theriot +theron +therrien +thersa +thevenar +thewalt +thi +thi-cuc +thia +thibaud +thibault +thibaut +thibeaul +thibert +thibodea +thibon +thiebaut +thieken +thiel +thiem +thien +thierry +thiery +thies +thiessen +thieu +thifault +thill +thimothy +thinh +think +thirugna +this dir +thisdel +thisner +thoai +thoi +thom +thoma +thomaier +thomalla +thomas +thomasa +thomasi +thomasia +thomasin +thomason +thomasse +thomassi +thombors +thomey +thomlins +thompson +thoms +thomsen +thomson +thon +thondanu +thor +thoreau +thorin +thorley +thorman +thorn +thornber +thornbur +thorndik +thorne +thornie +thornley +thornton +thorny +thorpe +thorsen +thorslun +thorson +thorstei +thorsten +thorvald +those +threader +thrift +throgmor +thu +thuan +thuesen +thum +thumm +thuong +thurgood +thurley +thurman +thurstan +thurston +thuswald +thuthuy +thuy +thyagara +ti +ti-cheng +ti-jeun +tian +tianbao +tiberghi +tibi +tibold +tibor +tice +ticheng +ticzon +tidball +tidd +tidwell +tiebold +tiebout +tiedeman +tiefenth +tiegs +tien +tien-bue +tien-chi +tiena +tienbuen +tienchie +tienyow +tierney +tiertza +tieu +tiff +tiffani +tiffanie +tiffany +tiffi +tiffie +tiffy +tigg +tigger +tigran +tihanyi +tiina +tijeun +tilak +tilbenny +tilda +tilden +tildi +tildie +tildy +tiler +tilk +tille +tiller +tilley +tillie +tillman +tilly +tilmon +tilson +tilton +tim +timeshee +timi +timleck +timler +timm +timmer +timmerma +timmi +timmie +timmins +timmons +timms +timmy +timo +timofei +timos +timoteo +timothea +timothee +timotheu +timothy +timpson +tims +timsit +timtsche +tin +tina +tine +tineke +tiner +ting +ting-shu +ting-tin +ting-yu +tingshuo +tingting +tingyu +tingyue +tini +tinney +tino +tintor +tiny +tio +tiong-ho +tip +tiphani +tiphanie +tiphany +tippett +tipping +tippy +tipton +tirrell +tischhau +tischler +tisdale +tisdall +tiseo +tish +tisha +titian +tito +titos +titus +tiu +tiwari +tiziano +tjahjadi +tjia +tjiong +tjoe +tjong +toan +toastmas +toba +tobe +tobey +tobi +tobiah +tobias +tobie +tobin +tobit +toby +tobye +tod +todaro +todd +toddi +toddie +toddy +todloski +todo +todorovi +toerless +toews +toft +togasaki +tognoni +tohama +toi +toiboid +toinette +tolar +toles +toletzka +tolgyess +tolle +tollefse +tolson +tolstoy +toly +tom +toma +tomacic +tomack +tomacruz +tomar +tomas +tomasett +tomasina +tomasine +tomaso +tomassi +tomasz +tomaszew +tombul +tome +tomes +tomi +tomkin +tomlin +tomlinso +tommaso +tommi +tommie +tommy +tomochek +tomohiro +tomoyosh +tompkins +tomy +tonelli +toney +tong +tongder +toni +tonia +tonie +tonkovic +tonnie +tonny +tonogai +tonu +tony +tonya +tonye +tookey +toolbox +toole +tooley +toolroom +tools +toolset +toomer +toone +toop +toothman +tootsie +tooyserk +toperzer +topgun +toplis +topo +topol +topp +tor +torain +torbert +torcac +tordocs +tore +torey +tori +torian +torie +toril +torin +tornes +tornqvis +torok +torr +torrance +torre +torrealb +torrell +torrence +torrens +torres +torrey +torrie +torrin +torry +torsten +torunn +tory +tosca +toscano +tosczak +toshach +toshachn +toshi +toshiaki +toshihir +toshinar +toss +tostenso +tota +totaro +toth +totino +totman +totten +totti +touati +touchett +toufic +tougas +toulson +toupin +tousigna +toussain +tova +tovah +tove +towaij +towers +towill +towler +towles +town +towney +townie +townley +towns +townsel +townsend +townson +towny +towsley +toyanne +toyoji +toyooka +tprl +trace +tracee +traces +tracey +tracey-m +trachsel +traci +tracie +tracy +tracz +trader +trae +trafford +trahan +trainer +training +trainor +trajan +tramar +trame +tran +tranfagl +trang +transki +translat +tranter +trasmund +traugott +traulich +traut +trautman +travel-p +travelpe +traver +travers +travis +travus +traxler +trayer +traylor +traynor +trecia +tredenni +tredway +treen +trees +trefor +trefry +trefts +tregenza +treisman +trek @ +tremain +tremaine +tremayne +tremblay +tremewan +trenna +trent +trentadu +trenton +tres +tres-sup +tresa +trescha +trese +tresrch +tressa +trev +trevar +trever +trevetha +trevitt +trevor +trey +tri +trial +triantap +tricci +tricia +trickett +tricord +trieu +trif +trifiro +triggian +trijanto +trimble +trina +trindy +trinh +trinidad +trink +trip +tripier +tripleho +tripp +tripps +tris +trish +trisha +trisic +trisko +trista +tristam +tristan +tristano +trittler +tritton +trivedi +trix +trixi +trixie +trixy +trocchi +trochu +troesch +trojak +tromm +trong +tropea +tropeano +trotsky +trotter +trottier +troubors +troup +trowbrid +troy +troyvoi +trpisovs +trstram +tru-fu +truchon +truda +trude +trudel +trudell +trudey +trudi +trudie +trudy +truebloo +truelove +trueman +truesdal +truffer +trujillo +trula +trull +truls +trumaine +truman +trumann +trumble +trung +trungy +trunley +truong +truran +trussler +trutsche +truus +tryfon +trying +tsai +tsai-hun +tsaihung +tsakalis +tsalikis +tsang +tsao +tsay +tschaja +tschann +tse +tse-lian +tseliang +tseng +tsenter +tsern +tsiakas +tsing +tsitsior +tsitsons +tso +tsolas +tsong-li +tsonglia +tsonos +tsortos +tsoucas +tsugio +tsui +tsuji +tsuk +tsun-kuo +tsun-yuk +tsuneo +tsung +tsunkuo +tsunoda +tsunyuk +tsuyoshi +ttisuppo +tu +tuan +tubb +tuck +tucker +tuckie +tucky +tudo +tudor +tue +tuen +tuesday +tuffo +tufford +tuhina +tuhr +tulga +tulio +tulip +tulk +tull +tulley +tullius +tullo +tully +tun-lin +tuna +tunali +tung +tung-min +tunghsin +tungming +tunon +tuoi +tuok +tuong +tupas +tupling +turbes +turbyfil +turchan +turcot +turcotte +turgay +turing +turkeer +turkey +turki +turkki +turkovic +turnbull +turner +turney +turpin +turrubia +turunen +turus +tushar +tussey +tusting +tutt +tuttle +tuxford +tuyen +tuyetphu +twa +twana +twarog +tweddle +tweetie +twidale +twiggy +twila +twiss +twitty +twolan +twx +twyla +twyman +twynham +twyver +txp +ty +tyack +tybalt +tybi +tybie +tyda +tye +tyke +tyler +tymchuk +tymon +tymothy +tynan +tyndall +tyne +tyner +typer +tyra +tyrance +tyroler +tyron +tyronda +tyrone +tyrrell +tyrus +tyson +tzanetea +tzeng +tzong-sh +tzong-ya +tzongshi +tzongyan +tzou +tzuang +tzung +uae +uathavik +uberig +uchida +uchiyama +udale +udall +uday +udaya +udayasek +ude +udell +uecker +ueda +uehara +ueyama +uffner +ufomadu +ugo +uguccion +ugwa +uhl +uhley +uhlhorn +uhlig +ukena +ula +ulberto +ulf +ulgen +uli +ulick +ulises +ulla +ully +ulric +ulrica +ulrich +ulrick +ulrika +ulrikaum +ulrike +ultrason +uludamar +ulysses +umakanta +umakanth +umberto +umeeda +umeh +umeko +umesh +umetsu +umphres +una +una-mae +unabr.di +underwoo +unger +unitt +unix +unixsupp +unkefer +unkles +unreg +unsoy +unxlb +upchurch +updt +upen +uppal +upshaw +upton +urata +urbain +urban +urbanic +urbano +urbanowi +urbanus +urbashi +urbick +urbielew +urbshas +uresh +uri +uriah +uriel +urnes +urow +urquhart +urs +ursa +ursala +ursola +urson +ursula +ursulina +ursuline +urwin +us +usa +useng +user +usman +usrouter +uswrsd +usyk +uszynski +uta +utah +utas +utilla +utpal +utpala +utsumi +uunko +uvieghar +uyar +vachel +vacher +vachiran +vachon +vaclav +vaculik +vadala +vadali +vadi +vadim +vafaie +vaglio-l +vahary +vahdat +vahe +vahedi +vahid +vaid +vail +vaillanc +vaillant +vairavan +vajentic +vajih +vakhshoo +vakili +val +valaree +valaria +valcourt +valda +valdemar +valdez +vale +valeda +valencia +valene +valenka +valenta +valente +valentia +valentij +valentik +valentin +valenzia +valera +valeria +valerie +valerien +valerio +valerius +valery +valerye +valia +valida +valin +valina +valinda +valiquet +valiveti +valko +valkyrie +valla +vallath +valle +vallee +vallejos +vallenty +vallet +valli +valliani +vallie +vallier +valliere +vallipur +vallozzi +vally +valma +valois +valorie +valry +valvasor +van +van alph +van alst +van atta +van bake +van bent +van coon +van den +van der +van dyke +van es +van eyk +van flee +van gaal +van hast +van hols +van hoy +van huls +van kast +van kess +van klin +van late +van leeu +van loon +van mans +van mete +van nest +van oors +van orde +van phil +van rijn +van rijs +van scho +van schy +van sick +van terr +van veen +van vrou +van weri +van-king +vanaman +vanasse +vance +vanda +vandagri +vandenbe +vandenbo +vandenhe +vandenki +vanderbi +vanderbo +vanderbu +vanderge +vanderhe +vanderho +vanderpo +vanderve +vanderwe +vandeval +vandevan +vandeven +vandewat +vandewou +vandomme +vandoorn +vandusen +vanessa +vangaste +vania +vanity +vankoote +vanlaar +vanliew +vann +vanna +vanni +vannie +vanny +vanpatte +vanstaal +vanstory +vanta +vanter +vanwormh +vanwyche +vanya +vanzella +varady +varaiya +varano +varda +vardy +varennes +vargas +vargo +varia +varkel +varkey +varley +varmazis +varsava +vartanes +varughes +varujan +vasan +vasantha +vasarhel +vaserfir +vasil +vasile +vasili +vasiliad +vasiliki +vasilis +vasilopo +vasily +vason +vasoufz +vassili +vassilik +vassilis +vassily +vassos +vastine +vasu +vasudeva +vaswani +vaterlau +vaters +vaughan +vaughn +vavarout +vavroch +vawter +vax +vazirani +vea +veale +veals +veciana +veck +ved +veda +vedant +veedell +veen +veena +vega +vehling +veillett +veilleux +veit +vejar +veklerov +veksler +vela +velasque +vele +veleta +velez +veljko +vella +velline +vellino +velma +veloria +veloz +velsher +velvet +vempati +ven +venbakm +vendette +veneice +veness +veng +venger +venguswa +venier +venita +venjohn +venkat +venkata +venkatak +venkatar +venkates +venkatra +venne +venner +venning +vennos +ventrone +ventura +venturin +venus +vera +veradis +verardi +verch +verde +verdi +verdonse +vere +verena +verene +verge +verghese +vergil +verheyde +verhoeve +verhotz +veriee +verifica +verile +verina +verinder +verine +verkroos +verla +verlyn +verma +vermeesc +vermette +vern +verna +verne +vernen +verney +vernice +verniece +vernita +vernon +vernor +verona +veronica +veronika +veronike +veroniqu +verreau +verrenne +verrilli +versace +versteeg +vertolli +verville +veryl +verzilli +veselko +vesna +vespa +vester +vesterda +vetil +vetrano +vetrie +vetter +vettese +vevay +vexler +vey +veyrat +vezeau +vezina +vi +viano +viau +viavant +vibeke +vic +vice +vicente +vicheara +vick +vickers +vicki +vickie +vicky +victoir +victor +victoria +vicuong +vida +vidaurri +videa +vidhyana +vidmer +vidovic +vidovik +viduya +vidya +viegas +vieger +viehweg +vieillar +vieira +vieiro +viens +viera +vieregge +vigeant +viitanie +vijai +vijay +vijaya +vijayala +vijya +vik +vikas +viki +vikki +vikky +vikram +vikrant +viktor +viktoria +vilas +vilayil +vilhan +vilhelm +vilhelmi +vilis +villanue +villarea +villella +villeneu +vilma +vilmanse +vilok +vilozny +vimal +vimi +vin +vina +vinas +vinay +vince +vincent +vincente +vincents +vincenty +vincenz +vincenzo +vineet +vinet +viney +vinh +vinita +vinnell +vinni +vinnie +vinny +vino +vinod +vinson +viola +violante +viole +violet +violeta +violetta +violette +vipi +vipul +viqar +virani +virant +virchick +virge +virgie +virgil +virgilio +virgina +virginia +virginie +virgoe +viriato +viriya +virk +virko +visentin +vish +vishal +vishwa +visiting +viskanta +visockis +vispi +vispy +visser +vistlik +visvanat +viswa +viswamit +vita +vitacco +vitaglia +vital +vite +vithit +vitia +vito +vitoria +vittoria +vittorio +viv +viva +viveca +vivek +vivi +vivia +vivian +viviana +viviane +vivianna +vivianne +vivie +vivien +viviene +vivienne +vivier +viviyan +vivyan +vivyanne +vlad +vladamir +vladdy +vladica +vladimir +vladisla +vlado +vlahos +vlanin +vm +vmbackup +vmchange +vmcord +vmsuppor +vmxa +vo +voadmin +vodicka +voduc +voelcker +vogel +vogt +voight +voitel +volchegu +volfe +volk +volker +volkmann +volkmer +vollmer +volz +von +von ende +von semm +von zube +voncanno +vonck +vonderha +vondersc +vonderwe +vonlehmd +vonni +vonnie +vonny +vonreich +vony +vonzant +voort +vopalens +vopni +voros +vosberg +vosburg +voss +vosu +vosup +voula +vowels +vrabel +vradmin +vrbetic +vreugden +vries +vrinda +vrouwerf +vu +vucinich +vuhoan +vuignier +vuncanno +vuong +vuquoc +vyachesl +vyaragav +vyas +vyjayant +vyky +vyza +wa +waals +wacheski +wachtste +wacker +wada +wadasing +waddell +wadden +waddick +waddingt +wade +wadkins +wadswort +waespe +waeyen +wagage +wager +wagers +waggoner +waghorne +waghray +wagle +wagner +wagoner +wahab +wahbe +wahju +wai +wai-bun +wai-chau +wai-chin +wai-fah +wai-hung +wai-leun +wai-man +waichi +waid +waidler +waifah +waigh +waihung +wain +waines +wainwrig +waissman +wait +waite +waiter +waitman +waja +wakabaya +wakako +wake +wakefiel +wakeham +wakim +walas +walbridg +walburga +walchli +wald +waldemar +walden +waldick +waldie +waldo +waldon +waldron +wales +waletzky +walford +walia +walid +walker +walkins +walkley +walkowia +wallace +wallache +wallaert +wallas +wallbank +waller +walles +walley +wallgren +wallie +wallis +walliw +walls +wally +waloff +walpole +walrand +walrond +walser +walsh +walston +walt +walta +waltdisn +walter +walters +walther +walton +waltraud +waly +walz +wambsgan +wamozart +wan +wanda +wandel +wandie +wandis +wandojo +wandsche +waneta +wang +wanids +wannell +wanner +wans +wanzeck +war +warburg +warburto +ward +warde +warden +wardle +wardrop +ware +wares +warfel +wargnier +warin +waring +wark +warkenti +warner +warnock +warnow +warrello +warren +warriner +warshaws +wartman +warun +warwick +waschuk +waserman +wash +washburn +washingt +wasim +wasitova +wasley +wasmeier +wassel +wasserma +wassim +wasson +wasylenk +wasylyk +wat +watanabe +watchmak +watchorn +waterhou +waterman +waters +watford +watkins +watkinso +watmore +watson +watters +wattier +watts +watznaue +waucheul +waugh +waverley +waverly +way +waybrigh +wayez +waylan +wayland +waylen +wayler +waylin +wayling +waylon +wayman +waymon +wayne +waytowic +weagle +weakley +wealch +weare +wease +weatherl +weathers +weaver +web +webb +webber +weber +webster +weckwert +weddell +wee-lin +wee-seng +wee-thon +weedmark +weeks +wefald +wefers +wegener +weger +wegner +wegrowic +wehara +wei +wei-i +wei-kun +wei-tsig +wei-yih +weibust +weicheng +weichung +weidar +weidenbo +weidenfe +weider +weidinge +weidner +weiguang +weiheng +weihs +weihsing +weii +weijia +weijie +weikang +weikuang +weikun +weil +weilin +weimin +weimong +weinbend +weinberg +weiner +weingart +weinkauf +weiping +weirich +weisenbe +weiser +weiss +weist +weitsig +weitz +weitzel +weiyih +welbie +welby +welch +weldon +welham +welker +wellard +welling +wells +wellstoo +welsch +welsford +welsh +welten +wemple +wen +wen-chie +wen-hann +wen-juin +wen-kai +wen-lian +wen-miin +wen-shan +wenbin +wenchien +wenchih +wenda +wendall +wendel +wendelin +wendell +wendi +wendi-st +wendie +wendista +wendling +wendong +wendt +wendy +wendye +weng +wenham +wenhann +wenjuin +wenliang +wenmiin +wennan +wennerst +wenona +wenonah +wensel +wenshan +wensley +wentwort +wentzcov +wenxi +wenyon +wenzel +wepf +weppler +werewolf +werick +weringh +werling +werner +wernher +wernik +werth +wertz +wery +wes +wesenber +wesley +wesolosk +wesolows +wessel +wessell +wesselma +wesselow +wessels +wessenbe +west +westbroo +westcott +wester +westfall +westgart +westlake +westleig +westley +westmore +weston +weston-d +westphal +westwood +wetherbe +wettelan +wetzel +wever +weyand +weylin +wga +whalen +whaley +whang +whatley +wheatley +wheaton +wheeler +wheelock +whei-may +wheimay +whelan +whelpdal +whetston +whetzel +whey +whey-min +wheyming +whidden +whinnery +whipple +whipps +whirpool +whirter +whisenhu +whiskin +whisler +whit +whitaker +whitby +whitcomb +whited +whitefor +whitehur +whiteman +whitesid +whitfiel +whitfill +whitford +whiting +whitlock +whitman +whitmore +whitney +whitsell +whitt +whittake +whittam +whitten +whittier +whitting +whitton +whitty +whitwam +whitwell +whitwort +whoi +whyte +wiatt +wichers +wichman +wicht +wichterl +wickes +wickham +wickie +widdicom +widdis +widdowso +widener +widianto +widows +widrig +widuch +wiebe +wiebren +wiederho +wiedman +wiedmann +wiegand +wieland +wiele +wienert +wiens +wiercioc +wierzba +wieser +wiesje +wieslaw +wieslawa +wiest +wigderso +wiggin +wiggins +wiggs +wight +wigle +wignall +wikkerin +wiklund +wil +wilbert +wilbur +wilburt +wilby +wilcox +wilczews +wilde +wildeman +wilden +wilder +wilderma +wildgen +wildman +wildon +wileen +wilek +wilemon +wilen +wilenius +wilensky +wiley +wilf +wilford +wilfred +wilfrid +wilgosh +wilhelm +wilhelmi +wilhelms +wilhelmu +wilhoit +wilie +wilke +wilken +wilkerso +wilkes +wilkie +wilkin +wilkins +wilkinso +wilko +wilks +will +willa +willabel +willamin +willard +willcock +willcox +willdon +willeke +willekes +willem +willemij +willemse +willenbr +willets +willett +willetta +willette +willey +willhoff +willi +william +williams +willie +willifor +willis +willison +willmore +willmott +willough +willow +willson +willy +willyt +wilma +wilmar +wilmer +wilmette +wilmont +wilmore +wilnai +wilona +wilone +wilow +wilsey +wilson +wilt +wilton +wiltz +wimberle +wimbush +wimmer +win +win-chyi +wina +winchest +winchyi +winde +windham +windom +windowin +windsor +windy +winerman +winfield +winfred +wing +wing-ki +wing-man +wing-tai +wingar +wingard +wingate +wingfiel +wingo +wingrove +wingtai +wini +winicki +winifiel +winifred +winje +winklema +winkler +winlow +winn +winna +winnah +winne +winni +winnie +winnifre +winningh +winningt +winnipeg +winny +winona +winonah +winsberg +winsborr +winsky +winslow +winstead +winston +winterbe +winters +winthrop +wintour +wippel +wiring +wirth +wiseman +wishewan +wisniews +wissinge +wissler +wit +witchlow +witham +withrow +witkowsk +witney +witold +witort +wits +witt +witte +wittich +wittie +wittik +wittman +witty +witzel +witzman +witzmann +wladysla +woan +wobbrock +woei-pen +woelffel +woessner +woinsky +wojciech +wojcik +wojdylo +wojnar +wojtecki +wokoma +wolczans +wolf +wolfe +wolfenba +wolff +wolfgang +wolfie +wolfman +wolford +wolfs +wolfson +wolfy +wolk +woll +woloshko +wolowidn +wolska +wolski +wolter +womack +womble +won +won-uk +wonda +wong +wonuk +wood +woodall +woodford +woodhall +woodie +woodley +woodlief +woodline +woodman +woodrow +woods +woodson +woodward +woody +woodyer +wooff +woojin +wook +wookie +woolery +wooley +woollam +woolley +woolwine +woon +wooster +wooten +wooters +wootton +worden +words fr +words in +working +world.fa +wormald +worms +worobey +woroszcz +worpell +worrall +worsley +worth +worthing +worthy +wortman +wozniak +wpms +wracher +wragg +wray +wren +wrennie +wriggles +wright +wrigley +writing +wrobel +wroblews +wruck +wsadmin +wsbackup +wu +wuan +wueppelm +wuertele +wun +wunderli +wurtz +wyant +wyatan +wyatt +wyble +wycoff +wydra +wye +wykoff +wylie +wyllie +wylma +wylo +wyman +wymard +wyn +wyndham +wynes +wynn +wynne +wynnie +wynny +wyrstiuk +wyss +wytenbur +wyzga-ta +xantippe +xavier +xaviera +xayaraj +xena +xenia +xenophon +xenos +xerxes +xever +xi-nam +xi-xian +xian +xiang-se +xiangsen +xianjie +xiao +xiao-min +xiaobing +xiaofei +xiaofeng +xiaoguan +xiaohui +xiaojing +xiaolei +xiaolin +xiaolong +xiaomei +xiaoping +xiaowen +xiaoxia +xie +xila +ximenes +ximenez +xin +xingchao +xingdong +xinlin +xinyi +xiong +xiqing +xixian +xmssuppo +xnew +xongxong +xpm +xpmbld +xpmbuild +xu +xuan +xuan-lie +xuefeng +xueling +xumin +xuong +xylia +xylina +xymenes +ya-shu +yabe +yach +yadollah +yaeger +yael +yafa +yaghutie +yahia +yahyapou +yakibchu +yakimovi +yakir +yalcin +yale +yali +yalonda +yamada +yamamoto +yamaoka +yamashit +yamato +yamaura +yamin +yan +yan-shek +yan-zhen +yanagida +yanan +yanaton +yance +yancey +yancy +yandell +yanjun +yank +yankee +yann +yanna +yannick +yannis +yano +yanosik +yanshek +yansun +yao +yao-nan +yaonan +yaphet +yaping +yarber +yarbroug +yard +yardley +yardy +yarlanda +yarnell +yaron +yarosh +yaroslav +yasar +yaser +yashu +yasmeen +yasmin +yassa +yassar +yassin +yasuaki +yasuhiro +yasuko +yasumasa +yasuo +yasushi +yasuura +yate +yates +yatin +yatish +yau +yau-fun +yau-mun +yau-wu +yaumun +yaung +yauwu +yavar +yavuz +yawar +yazdani +yazdi +yc +ye-sho +yea-ping +yeager +yeal +yeaping +yearwood +yeaton +yechezke +yeck +yedema +yee +yee-ning +yeh +yehuda +yehudi +yehudit +yeirnie +yelena +yelvingt +yemuna +yen +yen-heng +yen-jhy +yen-meng +yendall +yeng +yenheng +yenilmez +yenjhy +yenmeng +yenor +yeo +yeo-hoon +yeocheol +yeohoon +yeong-ch +yeong-eo +yeongchy +yeongeon +yerga +yerigan +yerneni +yesho +yetta +yettie +yetty +yetung +yeun +yeun-jyr +yeung +yeunjyr +yevette +yew-shin +yewshing +yezheng +yezi +yhu-tin +yhutin +yi +yi-min +yiannis +yie-tarn +yietarng +yifei +yigal +yih +yihban +yihchih +yii-mei +yiimei +yijean +yikhon +yiliang +yim +yimin +ying +ying-cdi +yingcdi +yishun +yitan +yiu-kong +yiukong +yixia +yixin +ylaine +yll-chen +yllcheng +ynes +ynez +yngvar +yoakum +yock +yoda +yodha +yoe +yogesh +yogeswar +yogi +yohe +yokan +yoke +yoke-kee +yokeley +yoko +yokono +yokoono +yolanda +yolande +yolane +yolanthe +yon-chun +yonchun +yong +yong-hyu +yongdong +yonghyun +yongil +yongli +yongxin +yonhong +yonik +yonk +yoo +yoon +yoon-mo +yoonjung +yoonmo +yoonsik +yoram +yorgo +yorgos +york +yorke +yorker +yoshi +yoshiaki +yoshihit +yoshikaw +yoshiko +yoshimi +yoshimit +yoshinob +yoshio +yoshioka +yoshiyam +yosi +yossaria +yost +yosuf +you-lian +youel +youji +youliang +youlin +youn +youn-jun +younan +younes +young +young-ba +young-il +young-ju +youngbai +youngblo +younger +youngill +younglov +youngman +youngqui +youngs +younjung +younkin +yount +youping +yousef +yousefpo +youssef +yousuf +youwen +yovonnda +yowell +ysabel +ytshak +yu +yu-chen +yu-chian +yu-chung +yu-hung +yu-kai +yu-pei +yu-wei +yuan +yuan-cha +yuan-shi +yuanchao +yuanjian +yuanshin +yuchen +yuchiang +yuchong +yudin +yudy +yue +yue-min +yue-shun +yuechu +yueh +yueh-min +yueh-shi +yuehming +yuehshio +yuehwern +yueli +yuen +yuen-pui +yuenglin +yueping +yueshun +yugang +yuh-dauh +yuh-jiun +yuh-tai +yuhanna +yuhdauh +yuhjiun +yuhn +yuhtai +yuill +yuji +yujie +yuk-wha +yuke +yukihiko +yukiko +yukinaga +yukinobu +yuklung +yuko +yuksel +yukuo +yul +yule +yulia +yulma +yum +yuma +yumi +yumurtac +yun +yun-sun +yundt +yung +yung-chi +yung-chu +yung-fu +yung-pin +yung-yu +yungchia +yungchun +yungfu +yunghuoy +yungmuh +yungping +yungyu +yunn-tzu +yunntzu +yunsun +yuon-kua +yuonkuan +yupei +yupin +yurach +yurchuk +yuri +yurik +yussuf +yuste +yutaka +yuting +yuval +yuwei +yuyi +yuyu +yvan +yves +yvet +yvette +yvon +yvonne +yvor +yzerman +z-80 +z80 +zabek +zabokrzy +zabransk +zabrina +zaccari +zaccaria +zach +zacharia +zacharie +zachary +zacherie +zachery +zack +zackaria +zadeh +zadorozn +zadow +zafar +zafarano +zafarull +zafer +zaga +zagorsek +zagorski +zagrodne +zahara +zaharoff +zaharych +zahid +zahir +zahirul +zahn +zahnley +zahra +zaia +zaid +zaidi +zaihua +zainab +zajac +zak +zaka +zakai +zakarow +zaker +zalameda +zalcstei +zalee +zaleski +zalite +zaliznya +zalman +zalokar +zaloker +zalzale +zaman +zampino +zan +zanariah +zander +zandra +zane +zanet +zaneta +zanetti +zanga +zani +zanni +zantiris +zapach +zappe +zara +zaragoza +zarah +zarate +zared +zarella +zaretsky +zargham +zaria +zarkel +zarla +zarlenga +zarrabia +zarrin +zatkovic +zatti +zattiero +zatylny +zauhar +zauner +zavadiuk +zaven +zawadka +zaydan +zazulak +zbib +zbignew +zbigniew +zbuda +zdenek +zdenka +zdenko +zea +zeb +zebadiah +zebedee +zebulen +zebulon +zecharia +zed +zedekiah +zedrick +zee +zeggil +zegray +zehir-ch +zehra +zeidler +zeiger +zeigler +zeilinge +zeimet +zein +zeina +zeisler +zeitler +zejing +zeke +zelda +zelenka +zelig +zeljko +zelko +zeller +zellers +zelma +zelsmann +zelwer +zemanek +zen +zena +zenaida +zenar +zeng +zenghong +zenia +zenisek +zenkevic +zenkner +zenon +zere +zerk +zero +zerriffi +zetterlu +zetts +zexiang +zhang +zhanna +zhao +zhaohong +zhaoqi +zhaoxu +zhelka +zhen +zheng +zhengyu +zhilan +zhishun +zhiwei +zhixin +zhiyong +zhong +zhongde +zhongfu +zhongjin +zhongqua +zhongxia +zhou +zhuezhi +zhuolin +zi-ping +zi-qiang +zia +ziad +ziai +zicheng +ziebarth +zieber +ziegler +ziehn +zielinsk +ziemba +zigrand +zilaie +zilberst +zilla +zilvia +zimmer +zimmerer +zimmerly +zimmerma +zina +zinati +zingale +zingeler +zinkie +zinn +zino +ziomek +zipcodes +ziping +zippora +ziqiang +zirko +zissis +zisu +zita +zitella +zitko +zito +zitzmann +ziva +zivanovi +zivilik +zivkovic +ziyi +ziyou +zlatin +zlotnick +znack +zoe +zoehner +zoel +zoellner +zoenka +zoerb +zofia +zohair +zohar +zohman +zohreh +zola +zollie +zollman +zolly +zolmer +zoltan +zonda +zondra +zone-chi +zonechin +zongyi +zonker +zonner +zonnya +zonoun +zoppel +zora +zorah +zoran +zorana +zoratti +zorina +zorine +zork +zorn +zorony +zorzi +zottola +zou +zouheir +zrobok +zsa zsa +zsazsa +zubair +zubans +zuben +zubricki +zuccarel +zuckerma +zug +zuhua +zuk +zukas +zukosky +zukovsky +zulema +zulfikar +zumel +zumhagen +zumpf +zunuzi +zuranato +zurawlev +zureik +zurl +zuzana +zvonar +zwi +zwick +zwicker +zwierzch +zybala +zyg +zygmunt +zylstra +zywiel \ No newline at end of file diff --git a/examples/crud_rest_api/README.rst b/examples/crud_rest_api/README.rst new file mode 100644 index 0000000000..ed29b8980a --- /dev/null +++ b/examples/crud_rest_api/README.rst @@ -0,0 +1,18 @@ +Quick How to Example On REST API +-------------------------------- + +Simple contacts application. + +Create an Admin user:: + + $ fabmanager create-admin + +Insert test data:: + + $ python testdata.py + +Run it:: + + $ fabmanager run + + diff --git a/examples/crud_rest_api/app/__init__.py b/examples/crud_rest_api/app/__init__.py new file mode 100644 index 0000000000..e4b61a2d28 --- /dev/null +++ b/examples/crud_rest_api/app/__init__.py @@ -0,0 +1,28 @@ +import logging + +from flask import Flask + +from flask_appbuilder import SQLA, AppBuilder + +#from sqlalchemy.engine import Engine +#from sqlalchemy import event + +logging.basicConfig(format='%(asctime)s:%(levelname)s:%(name)s:%(message)s') +logging.getLogger().setLevel(logging.DEBUG) + +app = Flask(__name__) +app.config.from_object('config') +db = SQLA(app) +appbuilder = AppBuilder(app, db.session) + +""" +Only include this for SQLLite constraints + +@event.listens_for(Engine, "connect") +def set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() +""" + +from app import models, views diff --git a/examples/crud_rest_api/app/models.py b/examples/crud_rest_api/app/models.py new file mode 100644 index 0000000000..5c2c616002 --- /dev/null +++ b/examples/crud_rest_api/app/models.py @@ -0,0 +1,47 @@ +import datetime +from sqlalchemy import Column, Integer, String, ForeignKey, Date +from sqlalchemy.orm import relationship +from flask_appbuilder import Model + +mindate = datetime.date(datetime.MINYEAR, 1, 1) + + +class ContactGroup(Model): + id = Column(Integer, primary_key=True) + name = Column(String(50), unique=True, nullable=False) + + def __repr__(self): + return self.name + + +class Gender(Model): + id = Column(Integer, primary_key=True) + name = Column(String(50), unique=True, nullable=False) + + def __repr__(self): + return self.name + + +class Contact(Model): + id = Column(Integer, primary_key=True) + name = Column(String(150), unique=True, nullable=False) + address = Column(String(564)) + birthday = Column(Date, nullable=True) + personal_phone = Column(String(20)) + personal_celphone = Column(String(20)) + contact_group_id = Column(Integer, ForeignKey('contact_group.id'), nullable=False) + contact_group = relationship("ContactGroup") + gender_id = Column(Integer, ForeignKey('gender.id'), nullable=False) + gender = relationship("Gender") + + def __repr__(self): + return self.name + + def month_year(self): + date = self.birthday or mindate + return datetime.datetime(date.year, date.month, 1) or mindate + + def year(self): + date = self.birthday or mindate + return datetime.datetime(date.year, 1, 1) + diff --git a/examples/crud_rest_api/app/translations/pt/LC_MESSAGES/messages.mo b/examples/crud_rest_api/app/translations/pt/LC_MESSAGES/messages.mo new file mode 100644 index 0000000000000000000000000000000000000000..7713f481f4c5f8abc88ce279b0830ba783c53591 GIT binary patch literal 516 zcmZuu%TB{E5DbD-5JKY2VYz{jOHz6$?n9I|R7Fl(rJ)y2ZsLZZU`Mut;KE<qtoi_)ujXXrQ>gE?1)_cehxr@9*zHT{AhFYJ0V7-s}iR!T2 zSQsj4d1B3`8*5dS#+hsNAQDR^DOn0_awRFuquEU`jA0o>i^**2Mxs>OlF(#%O0KHR zkXTU&!oh@H4o0IWSS;N6EG9!OxWyaDh+9FS>G>_<`NTg5&!f$YQxAbg5{ak0p7gKJ zZ*P2z8%tu%r75^on!@!hV=x_r0d)V2?z@c3tm2sN%qX;1xpl`v3XP#)d@mHP_(~MuH_qLEK88aoV63ZB8RrXo CSC6;= literal 0 HcmV?d00001 diff --git a/examples/crud_rest_api/app/translations/pt/LC_MESSAGES/messages.po b/examples/crud_rest_api/app/translations/pt/LC_MESSAGES/messages.po new file mode 100644 index 0000000000..3299f7099e --- /dev/null +++ b/examples/crud_rest_api/app/translations/pt/LC_MESSAGES/messages.po @@ -0,0 +1,27 @@ +# Portuguese translations for PROJECT. +# Copyright (C) 2014 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2014. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2014-01-13 00:29+0000\n" +"PO-Revision-Date: 2014-01-13 00:18+0000\n" +"Last-Translator: FULL NAME \n" +"Language-Team: pt \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 1.3\n" + +#: app/views.py:42 +msgid "List Groups" +msgstr "Lista de Grupos" + +#: app/views.py:43 +msgid "List Contacts" +msgstr "" + diff --git a/examples/crud_rest_api/app/views.py b/examples/crud_rest_api/app/views.py new file mode 100644 index 0000000000..4d9431f7d1 --- /dev/null +++ b/examples/crud_rest_api/app/views.py @@ -0,0 +1,45 @@ +from flask_appbuilder import ModelRestApi +from flask_appbuilder.models.sqla.interface import SQLAInterface +from . import db, appbuilder +from .models import ContactGroup, Gender, Contact + + +def fill_gender(): + try: + db.session.add(Gender(name='Male')) + db.session.add(Gender(name='Female')) + db.session.commit() + except: + db.session.rollback() + + +db.create_all() +fill_gender() + + +class ContactModelView(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + + +appbuilder.add_view( + ContactModelView, + "List Contacts", + icon="fa-envelope", + category="Contacts" +) + + +class GroupModelView(ModelRestApi): + resource_name = 'group' + datamodel = SQLAInterface(ContactGroup) + + +appbuilder.add_view( + GroupModelView, + "List Groups", + icon="fa-folder-open-o", + category="Contacts", + category_icon='fa-envelope' +) + diff --git a/examples/crud_rest_api/babel/babel.cfg b/examples/crud_rest_api/babel/babel.cfg new file mode 100644 index 0000000000..70e23ac634 --- /dev/null +++ b/examples/crud_rest_api/babel/babel.cfg @@ -0,0 +1,3 @@ +[python: **.py] +[jinja2: **/templates/**.html] +encoding = utf-8 diff --git a/examples/crud_rest_api/babel/messages.pot b/examples/crud_rest_api/babel/messages.pot new file mode 100644 index 0000000000..2e5a6fb97a --- /dev/null +++ b/examples/crud_rest_api/babel/messages.pot @@ -0,0 +1,27 @@ +# Translations template for PROJECT. +# Copyright (C) 2014 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2014. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2014-01-13 00:29+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 1.3\n" + +#: app/views.py:42 +msgid "List Groups" +msgstr "" + +#: app/views.py:43 +msgid "List Contacts" +msgstr "" + diff --git a/examples/crud_rest_api/config.py b/examples/crud_rest_api/config.py new file mode 100644 index 0000000000..bba522f789 --- /dev/null +++ b/examples/crud_rest_api/config.py @@ -0,0 +1,67 @@ +import os + +basedir = os.path.abspath(os.path.dirname(__file__)) + +CSRF_ENABLED = True +SECRET_KEY = '\2\1thisismyscretkey\1\2\e\y\y\h' + +OPENID_PROVIDERS = [ + {'name': 'Google', 'url': 'https://www.google.com/accounts/o8/id'}, + {'name': 'Yahoo', 'url': 'https://me.yahoo.com'}, + {'name': 'AOL', 'url': 'http://openid.aol.com/'}, + {'name': 'Flickr', 'url': 'http://www.flickr.com/'}, + {'name': 'MyOpenID', 'url': 'https://www.myopenid.com'}] + +SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db') +#SQLALCHEMY_DATABASE_URI = 'mysql://username:password@mysqlserver.local/quickhowto' +#SQLALCHEMY_DATABASE_URI = 'postgresql://scott:tiger@localhost:5432/myapp' +#SQLALCHEMY_ECHO = True +SQLALCHEMY_POOL_RECYCLE = 3 + +BABEL_DEFAULT_LOCALE = 'en' +BABEL_DEFAULT_FOLDER = 'translations' +LANGUAGES = { + 'en': {'flag': 'gb', 'name': 'English'}, + 'pt': {'flag': 'pt', 'name': 'Portuguese'}, + 'pt_BR': {'flag':'br', 'name': 'Pt Brazil'}, + 'es': {'flag': 'es', 'name': 'Spanish'}, + 'fr': {'flag': 'fr', 'name': 'French'}, + 'de': {'flag': 'de', 'name': 'German'}, + 'zh': {'flag': 'cn', 'name': 'Chinese'}, + 'ru': {'flag': 'ru', 'name': 'Russian'}, + 'pl': {'flag': 'pl', 'name': 'Polish'}, + 'el': {'flag': 'gr', 'name': 'Greek'}, + 'ja_JP': {'flag': 'jp', 'name': 'Japanese'} +} + + +#------------------------------ +# GLOBALS FOR GENERAL APP's +#------------------------------ +UPLOAD_FOLDER = basedir + '/app/static/uploads/' +IMG_UPLOAD_FOLDER = basedir + '/app/static/uploads/' +IMG_UPLOAD_URL = '/static/uploads/' +AUTH_TYPE = 1 +#AUTH_LDAP_SERVER = "ldap://dc.domain.net" +AUTH_ROLE_ADMIN = 'Admin' +AUTH_ROLE_PUBLIC = 'Public' +APP_NAME = "F.A.B. Example" +APP_THEME = "" # default +#APP_THEME = "cerulean.css" # COOL +#APP_THEME = "amelia.css" +#APP_THEME = "cosmo.css" +#APP_THEME = "cyborg.css" # COOL +#APP_THEME = "flatly.css" +#APP_THEME = "journal.css" +#APP_THEME = "readable.css" +#APP_THEME = "simplex.css" +#APP_THEME = "slate.css" # COOL +#APP_THEME = "spacelab.css" # NICE +#APP_THEME = "united.css" +#APP_THEME = "darkly.css" +#APP_THEME = "lumen.css" +#APP_THEME = "paper.css" +#APP_THEME = "sandstone.css" +#APP_THEME = "solar.css" +#APP_THEME = "superhero.css" + diff --git a/examples/crud_rest_api/run.py b/examples/crud_rest_api/run.py new file mode 100644 index 0000000000..85b1074626 --- /dev/null +++ b/examples/crud_rest_api/run.py @@ -0,0 +1,3 @@ +from app import app + +app.run(host='0.0.0.0', port=8080, debug=True) diff --git a/examples/crud_rest_api/testdata.py b/examples/crud_rest_api/testdata.py new file mode 100644 index 0000000000..e816126a8a --- /dev/null +++ b/examples/crud_rest_api/testdata.py @@ -0,0 +1,72 @@ +import logging +from app import db +from app.models import ContactGroup, Gender, Contact +import random +from datetime import datetime + +log = logging.getLogger(__name__) + +def get_random_name(names_list, size=1): + name_lst = [names_list[random.randrange(0, len(names_list))].decode("utf-8").capitalize() for i in range(0, size)] + return " ".join(name_lst) + + +try: + db.session.query(Contact).delete() + db.session.query(Gender).delete() + db.session.query(ContactGroup).delete() + db.session.commit() +except: + db.session.rollback() + +try: + groups = [] + groups.append(ContactGroup(name='Friends')) + groups.append(ContactGroup(name='Family')) + groups.append(ContactGroup(name='Work')) + db.session.add(groups[0]) + db.session.add(groups[1]) + db.session.add(groups[2]) + print(groups[0].id) + db.session.commit() +except Exception as e: + log.error("Creating Groups: %s", e) + db.session.rollback() + +try: + genders = list() + genders.append(Gender(name='Male')) + genders.append(Gender(name='Female')) + db.session.add(genders[0]) + db.session.add(genders[1]) + db.session.commit() +except Exception as e: + log.error("Creating Genders: %s", e) + db.session.rollback() + +f = open('NAMES.DIC', "rb") +names_list = [x.strip() for x in f.readlines()] + +f.close() + +for i in range(1, 1000): + c = Contact() + c.name = get_random_name(names_list, random.randrange(2, 6)) + c.address = 'Street ' + names_list[random.randrange(0, len(names_list))].decode("utf-8") + c.personal_phone = random.randrange(1111111, 9999999) + c.personal_celphone = random.randrange(1111111, 9999999) + c.contact_group = groups[random.randrange(0, 3)] + c.gender = genders[random.randrange(0, 2)] + year = random.choice(range(1900, 2012)) + month = random.choice(range(1, 12)) + day = random.choice(range(1, 28)) + c.birthday = datetime(year, month, day) + db.session.add(c) + try: + db.session.commit() + print("inserted", c) + except Exception as e: + log.error("Creating Contact: %s", e) + db.session.rollback() + + diff --git a/examples/quickhowto/app/models.py b/examples/quickhowto/app/models.py index 23a947b209..5c2c616002 100644 --- a/examples/quickhowto/app/models.py +++ b/examples/quickhowto/app/models.py @@ -1,17 +1,11 @@ import datetime -from sqlalchemy import Column, Integer, String, ForeignKey, Date, Enum +from sqlalchemy import Column, Integer, String, ForeignKey, Date from sqlalchemy.orm import relationship from flask_appbuilder import Model -from marshmallow import Schema, fields, ValidationError, post_load, pre_load mindate = datetime.date(datetime.MINYEAR, 1, 1) -def validate_name(n): - if n[0] != 'A': - raise ValidationError('Name must start with an A') - - class ContactGroup(Model): id = Column(Integer, primary_key=True) name = Column(String(50), unique=True, nullable=False) @@ -20,18 +14,6 @@ def __repr__(self): return self.name -class GroupCustomSchema(Schema): - name = fields.Str(validate=validate_name) - - @post_load - def process(self, data): - return ContactGroup(**data) - - -class ContactGroupSchema(Schema): - name = fields.Str(required=True) - - class Gender(Model): id = Column(Integer, primary_key=True) name = Column(String(50), unique=True, nullable=False) @@ -39,26 +21,18 @@ class Gender(Model): def __repr__(self): return self.name -import enum - - -class GenderEnum(enum.Enum): - male = 'Male' - female = 'Female' - class Contact(Model): id = Column(Integer, primary_key=True) - name = Column(String(150), unique=True, nullable=False) + name = Column(String(150), unique=True, nullable=False) address = Column(String(564)) birthday = Column(Date, nullable=True) personal_phone = Column(String(20)) personal_celphone = Column(String(20)) contact_group_id = Column(Integer, ForeignKey('contact_group.id'), nullable=False) contact_group = relationship("ContactGroup") - #gender_id = Column(Integer, ForeignKey('gender.id'), nullable=False) - #gender = relationship("Gender") - gender = Column(Enum(GenderEnum), nullable=False, info={"enum_class": GenderEnum}) + gender_id = Column(Integer, ForeignKey('gender.id'), nullable=False) + gender = relationship("Gender") def __repr__(self): return self.name @@ -67,19 +41,7 @@ def month_year(self): date = self.birthday or mindate return datetime.datetime(date.year, date.month, 1) or mindate - def some_function(self): - return "Hello {}".format(self.name) - def year(self): date = self.birthday or mindate return datetime.datetime(date.year, 1, 1) - -class ContactSchema(Schema): - name = fields.Str(required=True) - address = fields.Str() - birthday = fields.Date() - personal_phone = fields.Str() - personal_celphone = fields.Str() - contact_group = fields.Nested("ContactGroupSchema", required=True) - gender = relationship("Gender") diff --git a/examples/quickhowto/app/views.py b/examples/quickhowto/app/views.py index cf548f7090..3c9d58f60e 100644 --- a/examples/quickhowto/app/views.py +++ b/examples/quickhowto/app/views.py @@ -1,16 +1,10 @@ import calendar from flask_appbuilder import ModelView -from flask_appbuilder.views import redirect from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_appbuilder.charts.views import GroupByChartView from flask_appbuilder.models.group import aggregate_count -from flask_babel import lazy_gettext as _ -from flask_appbuilder.api import ModelRestApi -from flask_appbuilder.security.sqla.models import User -from flask_appbuilder.models.sqla.filters import FilterStartsWith, FilterEqualFunction - -from . import db, appbuilder, app -from .models import ContactGroup, Gender, Contact, ContactGroupSchema, ContactSchema, GroupCustomSchema +from . import db, appbuilder +from .models import ContactGroup, Gender, Contact def fill_gender(): @@ -25,9 +19,6 @@ def fill_gender(): class ContactModelView(ModelView): datamodel = SQLAInterface(Contact) - def post_add_redirect(self): - return redirect('model1viewwithredirects/show/{0}'.format(99999)) - list_columns = ['name', 'personal_celphone', 'birthday', 'contact_group.name'] base_order = ('name', 'asc') @@ -35,21 +26,33 @@ def post_add_redirect(self): ('Summary', {'fields': ['name', 'gender', 'contact_group']}), ( 'Personal Info', - {'fields': ['address', 'birthday', 'personal_phone', 'personal_celphone'], 'expanded': False}), + {'fields': + ['address', 'birthday', 'personal_phone', 'personal_celphone'], + 'expanded': False + } + ), ] add_fieldsets = [ ('Summary', {'fields': ['name', 'gender', 'contact_group']}), ( 'Personal Info', - {'fields': ['address', 'birthday', 'personal_phone', 'personal_celphone'], 'expanded': False}), + {'fields': + ['address', 'birthday', 'personal_phone', 'personal_celphone'], + 'expanded': False + } + ), ] edit_fieldsets = [ ('Summary', {'fields': ['name', 'gender', 'contact_group']}), ( 'Personal Info', - {'fields': ['address', 'birthday', 'personal_phone', 'personal_celphone'], 'expanded': False}), + {'fields': + ['address', 'birthday', 'personal_phone', 'personal_celphone'], + 'expanded': False + } + ), ] @@ -58,32 +61,6 @@ class GroupModelView(ModelView): related_views = [ContactModelView] -class GroupModelRestApi(ModelRestApi): - resource_name = 'group' - datamodel = SQLAInterface(ContactGroup) - - -class ContactModelRestApi(ModelRestApi): - resource_name = 'contact' - allow_browser_login = True - datamodel = SQLAInterface(Contact) - #list_columns = ['name', 'some_function'] - #base_filters = [['contact_group.name', FilterStartsWith, 'F']] - #list_model_schema = ContactSchema() - #base_filters = [['name', FilterStartsWith, 'a']] - #add_query_rel_fields = { - # 'contact_group': [['name', FilterStartsWith, 'F']] - #} - #edit_query_rel_fields = { - # 'contact_group': [['name', FilterStartsWith, 'F']] - #} - - #list_columns = ['name', 'address', 'personal_celphone'] - base_order = ('name', 'desc') - #list_exclude_columns = ['gender', 'contact_group_id','gender_id', 'id'] - #show_exclude_columns = ['name'] - - class ContactChartView(GroupByChartView): datamodel = SQLAInterface(Contact) chart_title = 'Grouped contacts' @@ -92,12 +69,12 @@ class ContactChartView(GroupByChartView): definitions = [ { - 'group' : 'contact_group', - 'series' : [(aggregate_count,'contact_group')] + 'group': 'contact_group', + 'series': [(aggregate_count, 'contact_group')] }, { - 'group' : 'gender', - 'series' : [(aggregate_count,'contact_group')] + 'group': 'gender', + 'series': [(aggregate_count, 'contact_group')] } ] @@ -132,19 +109,29 @@ class ContactTimeChartView(GroupByChartView): db.create_all() fill_gender() -appbuilder.add_view_no_menu(GroupModelRestApi) -appbuilder.add_view_no_menu(ContactModelRestApi) - -appbuilder.add_view(GroupModelView, "List Groups", icon="fa-folder-open-o", category="Contacts", category_icon='fa-envelope') -appbuilder.add_view(ContactModelView, "List Contacts", icon="fa-envelope", category="Contacts") +appbuilder.add_view( + GroupModelView, + "List Groups", + icon="fa-folder-open-o", + category="Contacts", + category_icon='fa-envelope' +) +appbuilder.add_view( + ContactModelView, + "List Contacts", + icon="fa-envelope", + category="Contacts" +) appbuilder.add_separator("Contacts") -appbuilder.add_view(ContactChartView, "Contacts Chart", icon="fa-dashboard", category="Contacts") -appbuilder.add_view(ContactTimeChartView, "Contacts Birth Chart", icon="fa-dashboard", category="Contacts") - - -@app.after_request -def after_request(response): - response.headers.add('Access-Control-Allow-Origin', '*') - response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') - response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') - return response +appbuilder.add_view( + ContactChartView, + "Contacts Chart", + icon="fa-dashboard", + category="Contacts" +) +appbuilder.add_view( + ContactTimeChartView, + "Contacts Birth Chart", + icon="fa-dashboard", + category="Contacts" +) diff --git a/examples/quickhowto/config.py b/examples/quickhowto/config.py index 82129cd3ae..bba522f789 100644 --- a/examples/quickhowto/config.py +++ b/examples/quickhowto/config.py @@ -4,7 +4,6 @@ CSRF_ENABLED = True SECRET_KEY = '\2\1thisismyscretkey\1\2\e\y\y\h' -JSON_AS_ASCII = False OPENID_PROVIDERS = [ {'name': 'Google', 'url': 'https://www.google.com/accounts/o8/id'}, @@ -16,7 +15,7 @@ SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db') #SQLALCHEMY_DATABASE_URI = 'mysql://username:password@mysqlserver.local/quickhowto' #SQLALCHEMY_DATABASE_URI = 'postgresql://scott:tiger@localhost:5432/myapp' -SQLALCHEMY_ECHO = True +#SQLALCHEMY_ECHO = True SQLALCHEMY_POOL_RECYCLE = 3 BABEL_DEFAULT_LOCALE = 'en' @@ -35,8 +34,6 @@ 'ja_JP': {'flag': 'jp', 'name': 'Japanese'} } -FAB_API_MAX_PAGE_SIZE = 30 -#FAB_API_SHOW_STACKTRACE = True #------------------------------ # GLOBALS FOR GENERAL APP's diff --git a/flask_appbuilder/__init__.py b/flask_appbuilder/__init__.py index 8da6221e77..4e2619a394 100644 --- a/flask_appbuilder/__init__.py +++ b/flask_appbuilder/__init__.py @@ -4,8 +4,9 @@ from .models.sqla import Model, Base, SQLA from .base import AppBuilder from .baseviews import expose, BaseView -from .views import ModelView, IndexView, SimpleFormView, PublicFormView, MasterDetailView, MultipleView, \ - RestCRUDView, CompactCRUDMixin +from .views import ModelView, IndexView, SimpleFormView, PublicFormView, \ + MasterDetailView, MultipleView, RestCRUDView, CompactCRUDMixin +from .api import ModelRestApi from .charts.views import GroupByChartView, DirectByChartView from .models.group import aggregate_count, aggregate_avg, aggregate_sum from .actions import action diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api.py index 12ad1498d2..5e33c27928 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api.py @@ -664,7 +664,10 @@ def _column2field(self, datamodel, column, nested=True): # Handle Enums elif datamodel.is_enum(column): required = not datamodel.is_nullable(column) - enum_class = datamodel.list_columns[column].info.get('enum_class') + enum_class = datamodel.list_columns[column].info.get( + 'enum_class', + datamodel.list_columns[column].type + ) field = EnumField(enum_class, dump_by=EnumField.VALUE, required=required) field.unique = datamodel.is_unique(column) return field From e000b401130b9dd17e78716f6378f298c7af7487 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 11:26:49 +0000 Subject: [PATCH 066/109] [api] code restructure and enable patch behaviour on put HTTP method --- docs/rest_api.rst | 4 +- flask_appbuilder/{api.py => api/__init__.py} | 186 +++++++------------ flask_appbuilder/api/convert.py | 141 ++++++++++++++ flask_appbuilder/tests/test_api.py | 20 +- 4 files changed, 221 insertions(+), 130 deletions(-) rename flask_appbuilder/{api.py => api/__init__.py} (87%) create mode 100644 flask_appbuilder/api/convert.py diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 7921cc056c..f936583799 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -514,7 +514,7 @@ inferred from our SQLAlchemy Models. But you can always use your own defined Marshmallow schemas independently for add, edit, list and show endpoints. -A validation error for PUT and POST methods returns HTTP 400 and the following JSON data:: +A validation error for PUT and POST methods returns HTTP 422 and the following JSON data:: { "message": { @@ -541,7 +541,7 @@ by sending a name that is a number:: } } -And we get an HTTP 400 (Bad request). +And we get an HTTP 422 (Unprocessable Entity). How to add custom validation? On our next example we only allow group names that start with a capital "A":: diff --git a/flask_appbuilder/api.py b/flask_appbuilder/api/__init__.py similarity index 87% rename from flask_appbuilder/api.py rename to flask_appbuilder/api/__init__.py index 5e33c27928..8485b4d70c 100644 --- a/flask_appbuilder/api.py +++ b/flask_appbuilder/api/__init__.py @@ -4,15 +4,14 @@ import traceback import prison from sqlalchemy.exc import IntegrityError +from marshmallow import ValidationError from flask import Blueprint, make_response, jsonify, request, current_app from werkzeug.exceptions import BadRequest from flask_babel import lazy_gettext as _ -from .security.decorators import permission_name, protect -from marshmallow import ValidationError, fields -from marshmallow_sqlalchemy import field_for -from marshmallow_enum import EnumField -from ._compat import as_unicode -from .const import ( +from .convert import Model2SchemaConverter +from ..security.decorators import permission_name, protect +from .._compat import as_unicode +from ..const import ( API_URI_RIS_KEY, API_ORDER_COLUMNS_RES_KEY, API_LABEL_COLUMNS_RES_KEY, @@ -139,7 +138,7 @@ class BaseApi(object): it's constructor will register your exposed urls on flask as a Blueprint. - This class does not expose any urls, + This class does not expose any urls, but provides a common base for all apis. """ @@ -280,7 +279,7 @@ def set_response_key_mappings(self, response, func, rison_args, **kwargs): v(self, response, **kwargs) def merge_current_user_permissions(self, response, **kwargs): - response[API_PERMISSIONS_RES_KEY] =\ + response[API_PERMISSIONS_RES_KEY] = \ self.appbuilder.sm.get_user_permissions_on_view( self.__class__.__name__ ) @@ -309,6 +308,16 @@ def response_400(self, message=None): message = message or "Arguments are not correct" return self.response(400, **{"message": message}) + def response_422(self, message=None): + """ + Helper method for HTTP 422 response + + :param message: Error message (str) + :return: HTTP Json response + """ + message = message or "Could not process entity" + return self.response(422, **{"message": message}) + def response_401(self): """ Helper method for HTTP 401 response @@ -587,6 +596,15 @@ class ContactModelView(ModelRestApi): Override to provide your own marshmallow Schema for JSON to SQLA dumps """ + model2schemaconverter = Model2SchemaConverter + """ + Override to use your own Model2SchemaConverter + (inherit from BaseModel2SchemaConverter) + """ + + def __init__(self): + super(ModelRestApi, self).__init__() + self.model2schemaconverter = self.model2schemaconverter(self.datamodel) def create_blueprint(self, appbuilder, *args, **kwargs): self._init_model_schemas() @@ -600,107 +618,20 @@ def _init_model_schemas(self): # Create Marshmalow schemas if one is not specified if self.list_model_schema is None: self.list_model_schema = \ - self._model_schema_factory(self.list_columns) + self.model2schemaconverter.convert(self.list_columns) if self.add_model_schema is None: self.add_model_schema = \ - self._model_schema_factory(self.add_columns, nested=False) + self.model2schemaconverter.convert(self.add_columns, nested=False) if self.edit_model_schema is None: self.edit_model_schema = \ - self._model_schema_factory(self.edit_columns, nested=False) + self.model2schemaconverter.convert( + self.edit_columns, + nested=False, + enum_dump_by_name=True + ) if self.show_model_schema is None: self.show_model_schema = \ - self._model_schema_factory(self.show_columns) - - @staticmethod - def _debug_schema(schema): - for k, v in schema._declared_fields.items(): - print(k, v) - - def _meta_schema_factory(self, columns, model, class_mixin): - from marshmallow_sqlalchemy.schema import ModelSchema - _model = model - if columns: - class MetaSchema(ModelSchema, class_mixin): - class Meta: - model = _model - fields = columns - strict = True - sqla_session = self.appbuilder.get_session - else: - class MetaSchema(ModelSchema, class_mixin): - class Meta: - model = _model - strict = True - sqla_session = self.appbuilder.get_session - return MetaSchema - - def _column2field(self, datamodel, column, nested=True): - _model = datamodel.obj - # Handle relations - if datamodel.is_relation(column) and nested: - required = not datamodel.is_nullable(column) - nested_model = datamodel.get_related_model(column) - nested_schema = self._model_schema_factory( - [], - nested_model, - nested=False - ) - if datamodel.is_relation_many_to_one(column): - many = False - elif datamodel.is_relation_many_to_many(column): - many = True - else: - many = False - field = fields.Nested(nested_schema, many=many, required=required) - field.unique = datamodel.is_unique(column) - return field - # Handle bug on marshmallow-sqlalchemy #163 - elif datamodel.is_relation(column): - required = not datamodel.is_nullable(column) - field = field_for(_model, column) - field.required = required - field.unique = datamodel.is_unique(column) - return field - # Handle Enums - elif datamodel.is_enum(column): - required = not datamodel.is_nullable(column) - enum_class = datamodel.list_columns[column].info.get( - 'enum_class', - datamodel.list_columns[column].type - ) - field = EnumField(enum_class, dump_by=EnumField.VALUE, required=required) - field.unique = datamodel.is_unique(column) - return field - if not hasattr(getattr(_model, column), '__call__'): - field = field_for(_model, column) - field.unique = datamodel.is_unique(column) - return field - - def _model_schema_factory(self, columns, model=None, nested=True): - """ - Will create a Marshmallow SQLAlchemy schema class - :param columns: List with columns to include - :return: ModelSchema object - """ - class SchemaMixin: - pass - - _model = model or self.datamodel.obj - _datamodel = self.datamodel.__class__(_model) - - ma_sqla_fields_override = {} - - _columns = list() - for column in columns: - ma_sqla_fields_override[column] = self._column2field( - _datamodel, - column, - nested - ) - _columns.append(column) - for k, v in ma_sqla_fields_override.items(): - setattr(SchemaMixin, k, v) - return self._meta_schema_factory(_columns, _model, SchemaMixin)() + self.model2schemaconverter.convert(self.show_columns) def _init_titles(self): """ @@ -742,7 +673,7 @@ def _init_properties(self): self._gen_labels_columns(self.list_columns) self.order_columns = self.order_columns or \ - self.datamodel.get_order_columns_list(list_columns=self.list_columns) + self.datamodel.get_order_columns_list(list_columns=self.list_columns) # Process excluded columns if not self.show_columns: self.show_columns = \ @@ -823,10 +754,10 @@ def post(self): try: item = self.add_model_schema.load(request.json) except ValidationError as err: - return self.response_400(message=err.messages) + return self.response_422(message=err.messages) # This validates custom Schema with custom validations if isinstance(item.data, dict): - return self.response_400(message=item.errors) + return self.response_422(message=item.errors) self.pre_add(item.data) try: self.datamodel.add(item.data, raise_exception=True) @@ -841,7 +772,7 @@ def post(self): } ) except IntegrityError as e: - return self.response_400(message=str(e.orig)) + return self.response_422(message=str(e.orig)) @expose('/', methods=['PUT']) @protect() @@ -854,15 +785,13 @@ def put(self, pk): if not item: return self.response_404() try: - # MERGE - for _col in self.add_columns: - print("COL!!! {}={}".format(_col, getattr(item, _col))) - item = self.edit_model_schema.load(request.json, instance=item) + data = self._merge_update_item(item, request.json) + item = self.edit_model_schema.load(data, instance=item) except ValidationError as err: - return self.response(400, **{'message': err.messages}) + return self.response_422(message=err.messages) # This validates custom Schema with custom validations if isinstance(item.data, dict): - return self.response(400, **{'message': item.errors}) + return self.response_422(message=item.errors) self.pre_update(item.data) try: self.datamodel.edit(item.data, raise_exception=True) @@ -874,7 +803,7 @@ def put(self, pk): many=False).data} ) except IntegrityError as e: - return self.response_400(message=str(e.orig)) + return self.response_422(message=str(e.orig)) @expose('/', methods=['DELETE']) @protect() @@ -890,7 +819,7 @@ def delete(self, pk): self.post_delete(item) return self.response(200, message='OK') except IntegrityError as e: - return self.response_400(message=str(e.orig)) + return self.response_422(message=str(e.orig)) def merge_label_columns(self, response, **kwargs): _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) @@ -955,7 +884,7 @@ def _get_item(self, pk, **kwargs): **{API_SELECT_COLUMNS_RIS_KEY: _pruned_select_cols} ) if _pruned_select_cols: - _show_model_schema = self._model_schema_factory(_pruned_select_cols) + _show_model_schema = self.model2schemaconverter.convert(_pruned_select_cols) else: _show_model_schema = self.show_model_schema @@ -982,7 +911,7 @@ def _get_list(self, **kwargs): ) if _pruned_select_cols: - _list_model_schema = self._model_schema_factory(_pruned_select_cols) + _list_model_schema = self.model2schemaconverter.convert(_pruned_select_cols) else: _list_model_schema = self.list_model_schema # handle filters @@ -1074,7 +1003,9 @@ def _get_field_info(self, field, filter_rel_field): # Handles related fields if isinstance(field, Related) or isinstance(field, RelatedList): _rel_interface = self.datamodel.get_related_interface(field.name) - _filters = _rel_interface.get_filters(_rel_interface.get_search_columns_list()) + _filters = _rel_interface.get_filters( + _rel_interface.get_search_columns_list() + ) if filter_rel_field: filters = _filters.add_filter_list(filter_rel_field) _values = _rel_interface.query(filters)[1] @@ -1117,6 +1048,25 @@ def _get_fields_info(self, cols, model_schema, filter_rel_fields): for col in cols ] + def _merge_update_item(self, model_item, data): + """ + Merge a model with a python data structure + This is useful to turn PUT method into a PATH also + :param model_item: SQLA Model + :param data: python data structure + :return: python data structure + """ + data_item = self.edit_model_schema.dump(model_item, many=False).data + for _col in self.edit_columns: + if _col not in data.keys(): + data[_col] = data_item[_col] + return data + + """ + ------------------------------------------------ + PRE AND POST METHODS + ------------------------------------------------ + """ def pre_update(self, item): """ Override this, this method is called before the update takes place. diff --git a/flask_appbuilder/api/convert.py b/flask_appbuilder/api/convert.py new file mode 100644 index 0000000000..724da45858 --- /dev/null +++ b/flask_appbuilder/api/convert.py @@ -0,0 +1,141 @@ +from marshmallow import fields +from marshmallow_sqlalchemy import field_for +from marshmallow_sqlalchemy.schema import ModelSchema +from marshmallow_enum import EnumField + + +class BaseModel2SchemaConverter(object): + + def __init__(self, datamodel): + """ + :param datamodel: SQLAInterface + """ + self.datamodel = datamodel + + def convert(self, columns, **kwargs): + pass + + +class Model2SchemaConverter(BaseModel2SchemaConverter): + """ + Class that converts Models to marshmallow Schemas + """ + + def __init__(self, datamodel): + """ + :param datamodel: SQLAInterface + """ + super(Model2SchemaConverter, self).__init__(datamodel) + + @staticmethod + def _debug_schema(schema): + for k, v in schema._declared_fields.items(): + print(k, v) + + def _meta_schema_factory(self, columns, model, class_mixin): + """ + Creates ModelSchema marshmallow-sqlalchemy + + :param columns: a list of columns to mix + :param model: Model + :param class_mixin: a marshamallow Schema to mix + :return: ModelSchema + """ + _model = model + if columns: + class MetaSchema(ModelSchema, class_mixin): + class Meta: + model = _model + fields = columns + strict = True + sqla_session = self.datamodel.session + else: + class MetaSchema(ModelSchema, class_mixin): + class Meta: + model = _model + strict = True + sqla_session = self.datamodel.session + return MetaSchema + + def _column2field(self, datamodel, column, nested=True, enum_dump_by_name=False): + _model = datamodel.obj + # Handle relations + if datamodel.is_relation(column) and nested: + required = not datamodel.is_nullable(column) + nested_model = datamodel.get_related_model(column) + nested_schema = self.convert( + [], + nested_model, + nested=False + ) + if datamodel.is_relation_many_to_one(column): + many = False + elif datamodel.is_relation_many_to_many(column): + many = True + else: + many = False + field = fields.Nested(nested_schema, many=many, required=required) + field.unique = datamodel.is_unique(column) + return field + # Handle bug on marshmallow-sqlalchemy #163 + elif datamodel.is_relation(column): + required = not datamodel.is_nullable(column) + field = field_for(_model, column) + field.required = required + field.unique = datamodel.is_unique(column) + return field + # Handle Enums + elif datamodel.is_enum(column): + required = not datamodel.is_nullable(column) + enum_class = datamodel.list_columns[column].info.get( + 'enum_class', + datamodel.list_columns[column].type + ) + if enum_dump_by_name: + enum_dump_by = EnumField.NAME + else: + enum_dump_by = EnumField.VALUE + field = EnumField(enum_class, dump_by=enum_dump_by, required=required) + field.unique = datamodel.is_unique(column) + return field + if not hasattr(getattr(_model, column), '__call__'): + field = field_for(_model, column) + field.unique = datamodel.is_unique(column) + return field + + def convert(self, columns, model=None, nested=True, enum_dump_by_name=False): + """ + Creates a Marshmallow ModelSchema class + + + :param columns: List with columns to include, if empty converts all on model + :param model: Override Model to convert + :param nested: Generate relation with nested schemas + :return: ModelSchema object + """ + super(Model2SchemaConverter, self).convert( + columns, + model=model, + nested=nested + ) + + class SchemaMixin: + pass + + _model = model or self.datamodel.obj + _datamodel = self.datamodel.__class__(_model) + + ma_sqla_fields_override = {} + + _columns = list() + for column in columns: + ma_sqla_fields_override[column] = self._column2field( + _datamodel, + column, + nested, + enum_dump_by_name + ) + _columns.append(column) + for k, v in ma_sqla_fields_override.items(): + setattr(SchemaMixin, k, v) + return self._meta_schema_factory(_columns, _model, SchemaMixin)() diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 9a87b3326b..74a067131c 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -56,7 +56,7 @@ def setUp(self): from flask import Flask from flask_appbuilder import AppBuilder from flask_appbuilder.models.sqla.interface import SQLAInterface - from flask_appbuilder.api import ModelRestApi + from flask_appbuilder import ModelRestApi self.app = Flask(__name__) self.basedir = os.path.abspath(os.path.dirname(__file__)) @@ -1355,7 +1355,7 @@ def test_update_val_size(self): uri, item ) - eq_(rv.status_code, 400) + eq_(rv.status_code, 422) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Longer than maximum length 50.') @@ -1371,8 +1371,7 @@ def test_update_mm_field(self): token = self.login(client, USERNAME, PASSWORD) pk = 1 item = dict( - children=[4], - field_string='0' + children=[4] ) uri = 'api/v1/modelmmapi/{}'.format(pk) rv = self.auth_client_put( @@ -1404,7 +1403,7 @@ def test_update_item_val_type(self): uri, item ) - eq_(rv.status_code, 400) + eq_(rv.status_code, 422) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_integer'][0], 'Not a valid integer.') @@ -1419,7 +1418,7 @@ def test_update_item_val_type(self): uri, item ) - eq_(rv.status_code, 400) + eq_(rv.status_code, 422) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Not a valid string.') @@ -1495,7 +1494,7 @@ def test_create_item_val_size(self): uri, item ) - eq_(rv.status_code, 400) + eq_(rv.status_code, 422) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Longer than maximum length 50.') @@ -1503,6 +1502,7 @@ def test_create_item_val_type(self): """ REST Api: Test create validate type """ + # Test integer as string client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) item = dict( @@ -1517,10 +1517,10 @@ def test_create_item_val_type(self): uri, item ) - eq_(rv.status_code, 400) + eq_(rv.status_code, 422) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_integer'][0], 'Not a valid integer.') - + # Test string as integer item = dict( field_string=MODEL1_DATA_SIZE, field_integer=MODEL1_DATA_SIZE, @@ -1532,7 +1532,7 @@ def test_create_item_val_type(self): uri, item ) - eq_(rv.status_code, 400) + eq_(rv.status_code, 422) data = json.loads(rv.data.decode('utf-8')) eq_(data['message']['field_string'][0], 'Not a valid string.') From 65e2adff778023ab2184dddb046c18a753f31d91 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 12:22:02 +0000 Subject: [PATCH 067/109] [api] created new add_api to replace app_view_no_menu for APIs --- docs/rest_api.rst | 6 +++--- examples/crud_rest_api/app/views.py | 20 ++++---------------- flask_appbuilder/base.py | 13 +++++++++++-- flask_appbuilder/tests/test_api.py | 26 +++++++++++++------------- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index f936583799..45dd62f6a1 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -81,7 +81,7 @@ using ``version`` and ``resource_name`` properties:: return self.response(200, message="Hello") - appbuilder.add_view_no_menu(MyFirstApi) + appbuilder.add_api(MyFirstApi) Now our endpoint will be:: @@ -264,7 +264,7 @@ Next, let's see how to create a private method:: return self.response(200, message="This is private") - appbuilder.add_view_no_menu(MyFirstApi) + appbuilder.add_api(MyFirstApi) Accessing this method as expected will return an HTTP 401 not authorized code and message:: @@ -406,7 +406,7 @@ First let's define a CRUD REST Api for our Group model resource:: resource_name = 'group' datamodel = SQLAInterface(ContactGroup) - appbuilder.add_view_no_menu(MyFirstApi) + appbuilder.add_api(MyFirstApi) Behind the scenes FAB uses marshmallow-sqlalchemy to infer the Model to a Marshmallow Schema, that can be safely serialized and deserialized. Let's recall our Model definition for ``ContactGroup``:: diff --git a/examples/crud_rest_api/app/views.py b/examples/crud_rest_api/app/views.py index 4d9431f7d1..3b167873be 100644 --- a/examples/crud_rest_api/app/views.py +++ b/examples/crud_rest_api/app/views.py @@ -17,29 +17,17 @@ def fill_gender(): fill_gender() -class ContactModelView(ModelRestApi): +class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) -appbuilder.add_view( - ContactModelView, - "List Contacts", - icon="fa-envelope", - category="Contacts" -) +appbuilder.add_api(ContactModelApi) -class GroupModelView(ModelRestApi): +class GroupModelApi(ModelRestApi): resource_name = 'group' datamodel = SQLAInterface(ContactGroup) -appbuilder.add_view( - GroupModelView, - "List Groups", - icon="fa-folder-open-o", - category="Contacts", - category_icon='fa-envelope' -) - +appbuilder.add_api(GroupModelApi) diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index d74a10840a..b52f6ecfbc 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -414,8 +414,8 @@ def add_view_no_menu(self, baseview, endpoint=None, static_folder=None): """ Add your views without creating a menu. - :param baseview: - A BaseView type class instantiated. + :param baseview: + A BaseView type class instantiated. """ baseview = self._check_and_init(baseview) @@ -433,6 +433,15 @@ def add_view_no_menu(self, baseview, endpoint=None, static_folder=None): log.warning(LOGMSG_WAR_FAB_VIEW_EXISTS.format(baseview.__class__.__name__)) return baseview + def add_api(self, baseview): + """ + Add a BaseApi class or child to AppBuilder + + :param baseview: A BaseApi type class + :return: The instantiated base view + """ + return self.add_view_no_menu(baseview) + def security_cleanup(self): """ This method is useful if you have changed diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 74a067131c..e471080bae 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -86,7 +86,7 @@ class Model1Api(ModelRestApi): } self.model1api = Model1Api - self.appbuilder.add_view_no_menu(Model1Api) + self.appbuilder.add_api(Model1Api) class Model1ApiFieldsInfo(Model1Api): datamodel = SQLAInterface(Model1) @@ -102,7 +102,7 @@ class Model1ApiFieldsInfo(Model1Api): ] self.model1apifieldsinfo = Model1ApiFieldsInfo - self.appbuilder.add_view_no_menu(Model1ApiFieldsInfo) + self.appbuilder.add_api(Model1ApiFieldsInfo) class Model1FuncApi(ModelRestApi): datamodel = SQLAInterface(Model1) @@ -120,7 +120,7 @@ class Model1FuncApi(ModelRestApi): } self.model1funcapi = Model1Api - self.appbuilder.add_view_no_menu(Model1FuncApi) + self.appbuilder.add_api(Model1FuncApi) class Model1ApiExcludeCols(ModelRestApi): datamodel = SQLAInterface(Model1) @@ -133,19 +133,19 @@ class Model1ApiExcludeCols(ModelRestApi): edit_exclude_columns = list_exclude_columns add_exclude_columns = list_exclude_columns - self.appbuilder.add_view_no_menu(Model1ApiExcludeCols) + self.appbuilder.add_api(Model1ApiExcludeCols) class Model1ApiOrder(ModelRestApi): datamodel = SQLAInterface(Model1) base_order = ('field_integer', 'desc') - self.appbuilder.add_view_no_menu(Model1ApiOrder) + self.appbuilder.add_api(Model1ApiOrder) class Model1ApiRestrictedPermissions(ModelRestApi): datamodel = SQLAInterface(Model1) base_permissions = ['can_get', 'can_info'] - self.appbuilder.add_view_no_menu(Model1ApiRestrictedPermissions) + self.appbuilder.add_api(Model1ApiRestrictedPermissions) class Model1ApiFiltered(ModelRestApi): datamodel = SQLAInterface(Model1) @@ -154,23 +154,23 @@ class Model1ApiFiltered(ModelRestApi): ['field_integer', FilterSmaller, 4] ] - self.appbuilder.add_view_no_menu(Model1ApiFiltered) + self.appbuilder.add_api(Model1ApiFiltered) class ModelWithEnumsApi(ModelRestApi): datamodel = SQLAInterface(ModelWithEnums) - self.appbuilder.add_view_no_menu(ModelWithEnumsApi) + self.appbuilder.add_api(ModelWithEnumsApi) class Model1BrowserLogin(ModelRestApi): datamodel = SQLAInterface(Model1) allow_browser_login = True - self.appbuilder.add_view_no_menu(Model1BrowserLogin) + self.appbuilder.add_api(Model1BrowserLogin) class ModelMMApi(ModelRestApi): datamodel = SQLAInterface(ModelMMParent) - self.appbuilder.add_view_no_menu(ModelMMApi) + self.appbuilder.add_api(ModelMMApi) class Model2Api(ModelRestApi): datamodel = SQLAInterface(Model2) @@ -182,7 +182,7 @@ class Model2Api(ModelRestApi): ] self.model2api = Model2Api - self.appbuilder.add_view_no_menu(Model2Api) + self.appbuilder.add_api(Model2Api) class Model2ApiFilteredRelFields(ModelRestApi): datamodel = SQLAInterface(Model2) @@ -201,7 +201,7 @@ class Model2ApiFilteredRelFields(ModelRestApi): edit_query_rel_fields = add_query_rel_fields self.model2apifilteredrelfields = Model2ApiFilteredRelFields - self.appbuilder.add_view_no_menu(Model2ApiFilteredRelFields) + self.appbuilder.add_api(Model2ApiFilteredRelFields) role_admin = self.appbuilder.sm.find_role('Admin') self.appbuilder.sm.add_user( @@ -1365,7 +1365,7 @@ def test_update_mm_field(self): """ model = ModelMMChild() model.field_string = 'update_m,m' - xpto = self.appbuilder.get_session.add(model) + self.appbuilder.get_session.add(model) self.appbuilder.get_session.commit() client = self.app.test_client() token = self.login(client, USERNAME, PASSWORD) From 261397288704ac6f93d0e0bba07d3e1ddc1c8b58 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 13:19:44 +0000 Subject: [PATCH 068/109] [api] [docs] reflect new add_api and partial updates --- docs/rest_api.rst | 266 ++++++++++++++++++++----------- flask_appbuilder/api/__init__.py | 4 +- 2 files changed, 178 insertions(+), 92 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 45dd62f6a1..55958706c8 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -21,7 +21,7 @@ custom API endpoints:: return self.response(200, message="Hello") - appbuilder.add_view_no_menu(MyFirstApi) + appbuilder.add_api(MyFirstApi) On the previous example, we are exposing an HTTP GET endpoint, @@ -59,7 +59,7 @@ so on our previous example:: return self.response(200, message="Hello") - appbuilder.add_view_no_menu(MyFirstApi) + appbuilder.add_api(MyFirstApi) Now our endpoint will be:: @@ -365,9 +365,10 @@ The previous example will enable cookie sessions on the all class:: def private(self) .... -On the previous example, we are enabling signed cookies on the ``private`` method +On the previous example, we are enabling signed cookies on the ``private`` method. Not that event then +valid a valid JWT is also accepted. -Model REST Api +Model REST API -------------- To automatically create a RESTfull CRUD Api from a database *Model*, use ``ModelRestApi`` class and @@ -504,84 +505,6 @@ Let's check if it exists (HTTP GET):: We get an HTTP 404 (Not found). -Validation and Custom Validation --------------------------------- - -Notice that by using marshmallow with SQLAlchemy, -we are validating field size, type and required fields out of the box. -This is done by marshmallow-sqlalchemy that automatically creates ModelSchema's -inferred from our SQLAlchemy Models. -But you can always use your own defined Marshmallow schemas independently -for add, edit, list and show endpoints. - -A validation error for PUT and POST methods returns HTTP 422 and the following JSON data:: - - { - "message": { - "": [ - "", - ... - ], - ... - } - } - -Next we will test some basic validation, first the field type -by sending a name that is a number:: - - $ curl XPOST http://localhost:8080/api/v1/group/ -d \ - '{"name": 1234}' \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" - { - "message": { - "name": [ - "Not a valid string." - ] - } - } - -And we get an HTTP 422 (Unprocessable Entity). - -How to add custom validation? On our next example we only allow -group names that start with a capital "A":: - - from marshmallow import Schema, fields, ValidationError, post_load - - - def validate_name(n): - if n[0] != 'A': - raise ValidationError('Name must start with an A') - - class GroupCustomSchema(Schema): - name = fields.Str(validate=validate_name) - - @post_load - def process(self, data): - return ContactGroup(**data) - -Then on our Api class:: - - class GroupModelRestApi(ModelRestApi): - resource_name = 'group' - add_model_schema = GroupCustomSchema() - edit_model_schema = GroupCustomSchema() - datamodel = SQLAInterface(ContactGroup) - -Let's try it out:: - - $ curl -v XPOST http://localhost:8080/api/v1/group/ -d \ - '{"name": "BOLA"}' \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TOKEN" - { - "message": { - "name": [ - "Name must start with an A" - ] - } - } - Information endpoint -------------------- @@ -604,12 +527,12 @@ First a birds eye view from the output of the **_info** endpoint:: "permissions": [...] } -Let's drill down this data structure, ``add_fields`` and ``edit_fields`` are similar +Let's drill down this data structure, ``add_columns`` and ``edit_columns`` are similar and serve to aid on rendering forms for add and edit so their response contains the following data structure:: { - "add_fields": [ + "add_columns": [ { "description": "", "label": "", @@ -624,7 +547,7 @@ following data structure:: ] } -Edit fields ``edit_fields`` is similar, but it's content may be different, since +Edit fields ``edit_columns`` is similar, but it's content may be different, since we can configure it in a distinct way Next, filters, this returns all the necessary info to render all possible filters allowed @@ -683,11 +606,11 @@ So, back to our example:: And to fetch the permissions and Add form fields info:: - $ curl 'http://localhost:8080/api/v1/group/_info?q=(keys:!(permissions,add_fields))' \ + $ curl 'http://localhost:8080/api/v1/group/_info?q=(keys:!(permissions,add_columns))' \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" { - "add_fields": [ ... ], + "add_columns": [ ... ], "permissions": [ "can_get", "can_post", @@ -699,11 +622,11 @@ And to fetch the permissions and Add form fields info:: To fetch meta data with internationalization use **_l_** URI key argument with i18n country code as the value. This will work on any HTTP GET endpoint:: - $ curl 'http://localhost:8080/api/v1/group/_info?q=(keys:!(permissions,add_fields))&_l_=pt' \ + $ curl 'http://localhost:8080/api/v1/group/_info?q=(keys:!(permissions,add_columns))&_l_=pt' \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" { - "add_fields": [ ... ], + "add_columns": [ ... ], "permissions": [ "can_get", "can_post", @@ -714,7 +637,7 @@ country code as the value. This will work on any HTTP GET endpoint:: Render meta data with *Portuguese*, labels, description, filters -The ``add_fields`` and ``edit_fields`` keys also render all possible +The ``add_columns`` and ``edit_columns`` keys also render all possible values from related fields, using our *quickhowto* example:: { @@ -981,3 +904,166 @@ Simple example using doted notation, FAB will infer the necessary join operation Locks all contacts, to groups whose name starts with "F". Using the provided test data on the quickhowto example, limits the contacts to family and friends. +Updates and Partial Updates +--------------------------- + +PUT methods allow for changing a **Model**. Allowed changes are controlled by +``edit_columns``:: + + class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + edit_columns = ['name'] + +First let's create a new contact:: + + curl -XPOST 'http://localhost:8080/api/v1/contact/' -H "Authorization: Bearer $TOKEN" -d \ + '{"name":"New Contact", "personal_celphone":"1234", "contact_group": 1, "gender":1}' \ + -H "Content-Type: application/json" + { + "id": 4, + "result": { + "address": null, + "birthday": null, + "contact_group": 1, + "gender": 1, + "name": "New Contact", + "personal_celphone": "1234", + "personal_phone": null + } + } + +So if you submit a change for ``personal_celphone``:: + + $ curl -v XPUT http://localhost:8080/api/v1/contact/4 -d \ + '{"name": "Change name", "personal_celphone": "this should not change"}' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "result": { + "name": "Change name" + } + } + +Let's confirm:: + + curl -XGET 'http://localhost:8080/api/v1/contact/4' -H "Authorization: Bearer $TOKEN" + { + .... + "id": "4", + "result": { + "address": null, + "birthday": null, + "contact_group": { + "id": 1, + "name": "Friends" + }, + "gender": { + "id": 1, + "name": "Male" + } + "name": "Change name", + "personal_celphone": "1234", + "personal_phone": null + } + } + +The PUT method may also work like a PATCH method, remove the ``edit_columns`` from the API class +and test a partial update:: + + $ curl -v XPUT http://localhost:8080/api/v1/contact/ -d \ + '{"personal_celphone": "4321"}' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "result": { + "address": null, + "birthday": null, + "contact_group": 1 + "gender": 1, + "name": "Change name", + "personal_celphone": "4321", + "personal_phone": null + } + } + + + +Validation and Custom Validation +-------------------------------- + +Notice that by using marshmallow with SQLAlchemy, +we are validating field size, type and required fields out of the box. +This is done by marshmallow-sqlalchemy that automatically creates ModelSchema's +inferred from our SQLAlchemy Models. +But you can always use your own defined Marshmallow schemas independently +for add, edit, list and show endpoints. + +A validation error for PUT and POST methods returns HTTP 422 and the following JSON data:: + + { + "message": { + "": [ + "", + ... + ], + ... + } + } + +Next we will test some basic validation, first the field type +by sending a name that is a number:: + + $ curl XPOST http://localhost:8080/api/v1/group/ -d \ + '{"name": 1234}' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "message": { + "name": [ + "Not a valid string." + ] + } + } + +And we get an HTTP 422 (Unprocessable Entity). + +How to add custom validation? On our next example we only allow +group names that start with a capital "A":: + + from marshmallow import Schema, fields, ValidationError, post_load + + + def validate_name(n): + if n[0] != 'A': + raise ValidationError('Name must start with an A') + + class GroupCustomSchema(Schema): + name = fields.Str(validate=validate_name) + + @post_load + def process(self, data): + return ContactGroup(**data) + +Then on our Api class:: + + class GroupModelRestApi(ModelRestApi): + resource_name = 'group' + add_model_schema = GroupCustomSchema() + edit_model_schema = GroupCustomSchema() + datamodel = SQLAInterface(ContactGroup) + +Let's try it out:: + + $ curl -v XPOST http://localhost:8080/api/v1/group/ -d \ + '{"name": "BOLA"}' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "message": { + "name": [ + "Name must start with an A" + ] + } + } + diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index 8485b4d70c..f5c669e2d4 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -93,10 +93,10 @@ def wraps(self, *args, **kwargs): def expose(url='/', methods=('GET',)): """ - Use this decorator to expose views on your view classes. + Use this decorator to expose API endpoints on your API classes. :param url: - Relative URL for the view + Relative URL for the endpoint :param methods: Allowed HTTP methods. By default only GET is allowed. """ From 0777bb010b7759cd22b845def9e256779e6d3a32 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 13:49:32 +0000 Subject: [PATCH 069/109] [api] New, validators_columns property to add custom validation --- docs/rest_api.rst | 10 ++++++++++ flask_appbuilder/api/__init__.py | 13 +++++++------ flask_appbuilder/api/convert.py | 9 ++++++--- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 55958706c8..d8eaa86f38 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -1067,3 +1067,13 @@ Let's try it out:: } } +Overriding completely the marshmallow Schema gives you complete control +but can become very cumbersome for **Models** with many attributes, there is +a simpler way of doing this using ``validators_columns`` property:: + + class GroupModelRestApi(ModelRestApi): + resource_name = 'group' + datamodel = SQLAInterface(ContactGroup) + validators_columns = {'name': validate_name} + + diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index f5c669e2d4..66457f9a2d 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -543,11 +543,9 @@ class MyView(ModelView): description_columns = {'name':'your models name column', 'address':'the address column'} """ - formatters_columns = None - """ Dictionary of formatter used to format the display of columns + validators_columns = None + """ Dictionary to add your own validators for forms """ - formatters_columns = {'some_date_col': lambda x: x.isoformat() } - """ add_query_rel_fields = None """ Add Customized query for related add fields. @@ -604,7 +602,11 @@ class ContactModelView(ModelRestApi): def __init__(self): super(ModelRestApi, self).__init__() - self.model2schemaconverter = self.model2schemaconverter(self.datamodel) + self.validators_columns = self.validators_columns or {} + self.model2schemaconverter = self.model2schemaconverter( + self.datamodel, + self.validators_columns + ) def create_blueprint(self, appbuilder, *args, **kwargs): self._init_model_schemas() @@ -656,7 +658,6 @@ def _init_properties(self): super(ModelRestApi, self)._init_properties() # Reset init props self.description_columns = self.description_columns or {} - self.formatters_columns = self.formatters_columns or {} self.list_exclude_columns = self.list_exclude_columns or [] self.show_exclude_columns = self.show_exclude_columns or [] self.add_exclude_columns = self.add_exclude_columns or [] diff --git a/flask_appbuilder/api/convert.py b/flask_appbuilder/api/convert.py index 724da45858..014ab37ce8 100644 --- a/flask_appbuilder/api/convert.py +++ b/flask_appbuilder/api/convert.py @@ -6,11 +6,12 @@ class BaseModel2SchemaConverter(object): - def __init__(self, datamodel): + def __init__(self, datamodel, validators_columns): """ :param datamodel: SQLAInterface """ self.datamodel = datamodel + self.validators_columns = validators_columns def convert(self, columns, **kwargs): pass @@ -21,11 +22,11 @@ class Model2SchemaConverter(BaseModel2SchemaConverter): Class that converts Models to marshmallow Schemas """ - def __init__(self, datamodel): + def __init__(self, datamodel, validators_columns): """ :param datamodel: SQLAInterface """ - super(Model2SchemaConverter, self).__init__(datamodel) + super(Model2SchemaConverter, self).__init__(datamodel, validators_columns) @staticmethod def _debug_schema(schema): @@ -101,6 +102,8 @@ def _column2field(self, datamodel, column, nested=True, enum_dump_by_name=False) if not hasattr(getattr(_model, column), '__call__'): field = field_for(_model, column) field.unique = datamodel.is_unique(column) + if column in self.validators_columns: + field.validate.append(self.validators_columns[column]) return field def convert(self, columns, model=None, nested=True, enum_dump_by_name=False): From 520119c61e015e0d99cb465ed41f242fe9ecfb20 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 14:10:02 +0000 Subject: [PATCH 070/109] [api] [tests] validators_columns property to add custom validation --- flask_appbuilder/tests/sqla/models.py | 6 ++ flask_appbuilder/tests/test_api.py | 88 ++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/flask_appbuilder/tests/sqla/models.py b/flask_appbuilder/tests/sqla/models.py index ea0b109577..30468d4d7a 100644 --- a/flask_appbuilder/tests/sqla/models.py +++ b/flask_appbuilder/tests/sqla/models.py @@ -1,10 +1,16 @@ import enum from sqlalchemy import Column, Integer, String, \ ForeignKey, Date, Float, Enum, DateTime, Table, UniqueConstraint +from marshmallow import ValidationError from sqlalchemy.orm import relationship from flask_appbuilder import Model, SQLA +def validate_name(n): + if n[0] != 'A': + raise ValidationError('Name must start with an A') + + class Model1(Model): id = Column(Integer, primary_key=True) field_string = Column(String(50), unique=True, nullable=False) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index e471080bae..caebac119c 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -6,7 +6,7 @@ from nose.tools import eq_ from flask_appbuilder import SQLA from .sqla.models import Model1, Model2, ModelWithEnums, TmpEnum, \ - ModelMMParent, ModelMMChild, insert_data + ModelMMParent, ModelMMChild, insert_data, validate_name from flask_appbuilder.models.sqla.filters import \ FilterGreater, FilterSmaller from flask_appbuilder.const import ( @@ -172,6 +172,13 @@ class ModelMMApi(ModelRestApi): self.appbuilder.add_api(ModelMMApi) + class Model1CustomValidationApi(ModelRestApi): + datamodel = SQLAInterface(Model1) + validators_columns = { + "field_string": validate_name + } + self.appbuilder.add_api(Model1CustomValidationApi) + class Model2Api(ModelRestApi): datamodel = SQLAInterface(Model2) list_columns = [ @@ -1279,6 +1286,41 @@ def test_update_item(self): eq_(model.field_integer, 0) eq_(model.field_float, 0.0) + def test_update_custom_validation(self): + """ + REST Api: Test update item custom validation + """ + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + pk = 3 + item = dict( + field_string="test_Put", + field_integer=0, + field_float=0.0 + ) + uri = 'api/v1/model1customvalidationapi/{}'.format(pk) + rv = self.auth_client_put( + client, + token, + uri, + item + ) + eq_(rv.status_code, 422) + pk = 3 + item = dict( + field_string="Atest_Put", + field_integer=0, + field_float=0.0 + ) + uri = 'api/v1/model1customvalidationapi/{}'.format(pk) + rv = self.auth_client_put( + client, + token, + uri, + item + ) + eq_(rv.status_code, 200) + def test_update_item_base_filters(self): """ REST Api: Test update item with base filters @@ -1475,6 +1517,50 @@ def test_create_item(self): eq_(model.field_integer, MODEL1_DATA_SIZE+1) eq_(model.field_float, float(MODEL1_DATA_SIZE+1)) + def test_create_item_custom_validation(self): + """ + REST Api: Test create item custom validation + """ + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + item = dict( + field_string="test{}".format(MODEL1_DATA_SIZE+1), + field_integer=MODEL1_DATA_SIZE+1, + field_float=float(MODEL1_DATA_SIZE+1), + field_date=None + ) + uri = 'api/v1/model1customvalidationapi/' + rv = self.auth_client_post( + client, + token, + uri, + item + ) + data = json.loads(rv.data.decode('utf-8')) + eq_(rv.status_code, 422) + eq_(data, { + "message": { + "field_string": [ + "Name must start with an A" + ] + } + }) + item = dict( + field_string="A{}".format(MODEL1_DATA_SIZE+1), + field_integer=MODEL1_DATA_SIZE+1, + field_float=float(MODEL1_DATA_SIZE+1), + field_date=None + ) + uri = 'api/v1/model1customvalidationapi/' + rv = self.auth_client_post( + client, + token, + uri, + item + ) + data = json.loads(rv.data.decode('utf-8')) + eq_(rv.status_code, 201) + def test_create_item_val_size(self): """ REST Api: Test create validate size From 44112c2037886bddc199028e730b5aa55b4108e5 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 16:21:12 +0000 Subject: [PATCH 071/109] [api] Fix, remove User API security model API will be added later --- flask_appbuilder/security/api.py | 81 ----------------------- flask_appbuilder/security/manager.py | 10 +-- flask_appbuilder/security/sqla/manager.py | 1 - 3 files changed, 2 insertions(+), 90 deletions(-) diff --git a/flask_appbuilder/security/api.py b/flask_appbuilder/security/api.py index ec0f7f76ab..44aefec170 100644 --- a/flask_appbuilder/security/api.py +++ b/flask_appbuilder/security/api.py @@ -81,84 +81,3 @@ def refresh(self): ) } return self.response(200, **resp) - - -class UserApi(ModelRestApi): - resource_name = 'user' - - label_columns = { - 'get_full_name': lazy_gettext('Full Name'), - 'first_name': lazy_gettext('First Name'), - 'last_name': lazy_gettext('Last Name'), - 'username': lazy_gettext('User Name'), - 'password': lazy_gettext('Password'), - 'active': lazy_gettext('Is Active?'), - 'email': lazy_gettext('Email'), - 'roles': lazy_gettext('Role'), - 'last_login': lazy_gettext('Last login'), - 'login_count': lazy_gettext('Login count'), - 'fail_login_count': lazy_gettext('Failed login count'), - 'created_on': lazy_gettext('Created on'), - 'created_by': lazy_gettext('Created by'), - 'changed_on': lazy_gettext('Changed on'), - 'changed_by': lazy_gettext('Changed by') - } - - description_columns = { - 'first_name': lazy_gettext( - 'Write the user first name or names' - ), - 'last_name': lazy_gettext( - 'Write the user last name' - ), - 'username': lazy_gettext( - 'Username valid for authentication on DB or LDAP, unused for OID auth' - ), - 'password': lazy_gettext( - 'Please use a good password policy, this application does not check this for you' - ), - 'active': lazy_gettext( - 'It\'s not a good policy to remove a user, just make it inactive' - ), - 'email': lazy_gettext( - 'The user\'s email, this will also be used for OID auth' - ), - 'roles': lazy_gettext( - 'The user role on the application, this will associate with a list of permissions' - ), - 'conf_password': lazy_gettext( - 'Please rewrite the user\'s password to confirm' - ) - } - - list_columns = [ - 'first_name', - 'last_name', - 'username', - 'email', - 'active', - 'roles' - ] - show_columns = [ - 'first_name', - 'last_name', - 'username', - 'active', - 'email', - 'roles' - ] - add_columns = [ - 'first_name', - 'last_name', - 'username', - 'active', - 'email', - 'roles'] - edit_columns = [ - 'first_name', - 'last_name', - 'username', - 'active', - 'email', - 'roles' - ] diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index cefa7c82a3..86c4855941 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -10,10 +10,7 @@ from flask_jwt_extended import current_user as current_user_jwt from flask_openid import OpenID from flask_babel import lazy_gettext as _ -from .api import ( - SecurityApi, - UserApi -) +from .api import SecurityApi from .views import ( AuthDBView, AuthOIDView, @@ -191,8 +188,6 @@ class BaseSecurityManager(AbstractSecurityManager): # API security_api = SecurityApi """ Override if you want your own Security API login endpoint """ - user_api = UserApi - """ Override if you want your own user API """ rolemodelview = RoleModelView permissionmodelview = PermissionModelView @@ -537,8 +532,7 @@ def _azure_jwt_token_parse(self, id_token): def register_views(self): # Security APIs - self.appbuilder.add_view_no_menu(self.security_api) - self.appbuilder.add_view_no_menu(self.user_api) + self.appbuilder.add_api(self.security_api) if self.auth_user_registration: if self.auth_type == AUTH_DB: diff --git a/flask_appbuilder/security/sqla/manager.py b/flask_appbuilder/security/sqla/manager.py index f7b1110211..3acadcf716 100644 --- a/flask_appbuilder/security/sqla/manager.py +++ b/flask_appbuilder/security/sqla/manager.py @@ -57,7 +57,6 @@ def __init__(self, appbuilder): self.permissionmodelview.datamodel = SQLAInterface(self.permission_model) self.viewmenumodelview.datamodel = SQLAInterface(self.viewmenu_model) self.permissionviewmodelview.datamodel = SQLAInterface(self.permissionview_model) - self.user_api.datamodel = SQLAInterface(self.user_model) self.create_db() @property From 15877b2dabac942e67c64a68295b7969860452cb Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 16:29:40 +0000 Subject: [PATCH 072/109] [api] [examples] Fix, base api --- examples/base_api/app/api.py | 4 +--- examples/base_api/run.py | 2 +- examples/crud_rest_api/app/__init__.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/base_api/app/api.py b/examples/base_api/app/api.py index cb97227f9b..3d792fd342 100644 --- a/examples/base_api/app/api.py +++ b/examples/base_api/app/api.py @@ -7,8 +7,6 @@ class MyFirstApi(BaseApi): resource_name = 'myfirst' - version = 'v2' - route_base = '/newapi/v3/nice' @expose('/greeting') def greeting(self): @@ -47,4 +45,4 @@ def error(self): raise Exception -appbuilder.add_view_no_menu(MyFirstApi) +appbuilder.add_api(MyFirstApi) diff --git a/examples/base_api/run.py b/examples/base_api/run.py index ddd39c1a08..85b1074626 100644 --- a/examples/base_api/run.py +++ b/examples/base_api/run.py @@ -1,3 +1,3 @@ -from . import app +from app import app app.run(host='0.0.0.0', port=8080, debug=True) diff --git a/examples/crud_rest_api/app/__init__.py b/examples/crud_rest_api/app/__init__.py index e4b61a2d28..8b9e8964b7 100644 --- a/examples/crud_rest_api/app/__init__.py +++ b/examples/crud_rest_api/app/__init__.py @@ -25,4 +25,4 @@ def set_sqlite_pragma(dbapi_connection, connection_record): cursor.close() """ -from app import models, views +from . import models, views From 4a3d921ec801e74001b806e667b1e239d10bb912 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 16:32:33 +0000 Subject: [PATCH 073/109] [api] [tests] Fix, number of views after removal of UserApi --- flask_appbuilder/tests/test_base.py | 2 +- flask_appbuilder/tests/test_mongoengine.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flask_appbuilder/tests/test_base.py b/flask_appbuilder/tests/test_base.py index 9bdd3e8e26..0acc53d90b 100644 --- a/flask_appbuilder/tests/test_base.py +++ b/flask_appbuilder/tests/test_base.py @@ -287,7 +287,7 @@ def test_fab_views(self): """ Test views creation and registration """ - eq_(len(self.appbuilder.baseviews), 34) # current minimal views are 34 + eq_(len(self.appbuilder.baseviews), 33) # current minimal views are 34 def test_back(self): """ diff --git a/flask_appbuilder/tests/test_mongoengine.py b/flask_appbuilder/tests/test_mongoengine.py index 574cc078a5..e76633e016 100644 --- a/flask_appbuilder/tests/test_mongoengine.py +++ b/flask_appbuilder/tests/test_mongoengine.py @@ -236,7 +236,7 @@ def test_fab_views(self): """ Test views creation and registration """ - eq_(len(self.appbuilder.baseviews), 26) # current minimal views are 26 + eq_(len(self.appbuilder.baseviews), 25) # current minimal views are 26 def test_index(self): """ From 69ffa3b962e7cf64e52e46d7ee51023329ab1715 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 16:43:39 +0000 Subject: [PATCH 074/109] [api] Log warn of old API deprecation on 1.15.X --- flask_appbuilder/views.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/flask_appbuilder/views.py b/flask_appbuilder/views.py index 0216f5ac9c..83cb87e1ee 100644 --- a/flask_appbuilder/views.py +++ b/flask_appbuilder/views.py @@ -164,6 +164,7 @@ def _get_modelview_urls(self, modelview_urls=None): @has_access_api @permission_name('list') def api(self): + log.warning("This is API is deprecated and will be removed on 1.15.X") view_name = self.__class__.__name__ api_urls = self._get_api_urls() modelview_urls = self._get_modelview_urls() @@ -206,6 +207,7 @@ def api(self): def api_read(self): """ """ + log.warning("This is API is deprecated and will be removed on 1.15.X") # Get arguments for ordering if get_order_args().get(self.__class__.__name__): order_column, order_direction = get_order_args().get(self.__class__.__name__) @@ -247,6 +249,7 @@ def show_item_dict(self, item): def api_get(self, pk): """ """ + log.warning("This is API is deprecated and will be removed on 1.15.X") # Get arguments for ordering item = self.datamodel.get(pk, self._base_filters) if not item: @@ -264,6 +267,7 @@ def api_get(self, pk): @has_access_api @permission_name('add') def api_create(self): + log.warning("This is API is deprecated and will be removed on 1.15.X") get_filter_args(self._filters) exclude_cols = self._filters.get_relation_cols() form = self.add_form.refresh() @@ -294,6 +298,7 @@ def api_create(self): @has_access_api @permission_name('edit') def api_update(self, pk): + log.warning("This is API is deprecated and will be removed on 1.15.X") get_filter_args(self._filters) exclude_cols = self._filters.get_relation_cols() @@ -340,6 +345,7 @@ def api_update(self, pk): @has_access_api @permission_name('delete') def api_delete(self, pk): + log.warning("This is API is deprecated and will be removed on 1.15.X") item = self.datamodel.get(pk, self._base_filters) if not item: abort(404) @@ -382,6 +388,7 @@ def api_column_add(self, col_name): :param col_name: The related column name :return: JSON response """ + log.warning("This is API is deprecated and will be removed on 1.15.X") filter_rel_fields = None if self.add_form_query_rel_fields: filter_rel_fields = self.add_form_query_rel_fields.get(col_name) @@ -402,6 +409,7 @@ def api_column_edit(self, col_name): :param col_name: The related column name :return: JSON response """ + log.warning("This is API is deprecated and will be removed on 1.15.X") filter_rel_fields = None if self.edit_form_query_rel_fields: filter_rel_fields = self.edit_form_query_rel_fields @@ -416,6 +424,7 @@ def api_column_edit(self, col_name): def api_readvalues(self): """ """ + log.warning("This is API is deprecated and will be removed on 1.15.X") # Get arguments for ordering if get_order_args().get(self.__class__.__name__): order_column, order_direction = get_order_args().get(self.__class__.__name__) @@ -436,7 +445,6 @@ def api_readvalues(self): return response - class ModelView(RestCRUDView): """ This is the CRUD generic view. From 060c57b849597ce6b6751e0e460346cb077f3d5d Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 16:56:37 +0000 Subject: [PATCH 075/109] [api] Fix text for Log warn of old API deprecation on 1.15.X --- flask_appbuilder/views.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/flask_appbuilder/views.py b/flask_appbuilder/views.py index 83cb87e1ee..cb4da56e2e 100644 --- a/flask_appbuilder/views.py +++ b/flask_appbuilder/views.py @@ -164,7 +164,7 @@ def _get_modelview_urls(self, modelview_urls=None): @has_access_api @permission_name('list') def api(self): - log.warning("This is API is deprecated and will be removed on 1.15.X") + log.warning("This API is deprecated and will be removed on 1.15.X") view_name = self.__class__.__name__ api_urls = self._get_api_urls() modelview_urls = self._get_modelview_urls() @@ -207,7 +207,7 @@ def api(self): def api_read(self): """ """ - log.warning("This is API is deprecated and will be removed on 1.15.X") + log.warning("This API is deprecated and will be removed on 1.15.X") # Get arguments for ordering if get_order_args().get(self.__class__.__name__): order_column, order_direction = get_order_args().get(self.__class__.__name__) @@ -249,7 +249,7 @@ def show_item_dict(self, item): def api_get(self, pk): """ """ - log.warning("This is API is deprecated and will be removed on 1.15.X") + log.warning("This API is deprecated and will be removed on 1.15.X") # Get arguments for ordering item = self.datamodel.get(pk, self._base_filters) if not item: @@ -267,7 +267,7 @@ def api_get(self, pk): @has_access_api @permission_name('add') def api_create(self): - log.warning("This is API is deprecated and will be removed on 1.15.X") + log.warning("This API is deprecated and will be removed on 1.15.X") get_filter_args(self._filters) exclude_cols = self._filters.get_relation_cols() form = self.add_form.refresh() @@ -298,7 +298,7 @@ def api_create(self): @has_access_api @permission_name('edit') def api_update(self, pk): - log.warning("This is API is deprecated and will be removed on 1.15.X") + log.warning("This API is deprecated and will be removed on 1.15.X") get_filter_args(self._filters) exclude_cols = self._filters.get_relation_cols() @@ -345,7 +345,7 @@ def api_update(self, pk): @has_access_api @permission_name('delete') def api_delete(self, pk): - log.warning("This is API is deprecated and will be removed on 1.15.X") + log.warning("This API is deprecated and will be removed on 1.15.X") item = self.datamodel.get(pk, self._base_filters) if not item: abort(404) @@ -388,7 +388,7 @@ def api_column_add(self, col_name): :param col_name: The related column name :return: JSON response """ - log.warning("This is API is deprecated and will be removed on 1.15.X") + log.warning("This API is deprecated and will be removed on 1.15.X") filter_rel_fields = None if self.add_form_query_rel_fields: filter_rel_fields = self.add_form_query_rel_fields.get(col_name) @@ -409,7 +409,7 @@ def api_column_edit(self, col_name): :param col_name: The related column name :return: JSON response """ - log.warning("This is API is deprecated and will be removed on 1.15.X") + log.warning("This API is deprecated and will be removed on 1.15.X") filter_rel_fields = None if self.edit_form_query_rel_fields: filter_rel_fields = self.edit_form_query_rel_fields @@ -424,7 +424,7 @@ def api_column_edit(self, col_name): def api_readvalues(self): """ """ - log.warning("This is API is deprecated and will be removed on 1.15.X") + log.warning("This API is deprecated and will be removed on 1.15.X") # Get arguments for ordering if get_order_args().get(self.__class__.__name__): order_column, order_direction = get_order_args().get(self.__class__.__name__) From 3d8684b08bcc33cac99a18a62550f95770595700 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 17:47:18 +0000 Subject: [PATCH 076/109] [api] Fix better sanitize of rison args --- flask_appbuilder/api/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index 66457f9a2d..ae48a30a34 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -916,7 +916,10 @@ def _get_list(self, **kwargs): else: _list_model_schema = self.list_model_schema # handle filters - joined_filters = self._handle_filters_args(_args) + try: + joined_filters = self._handle_filters_args(_args) + except Exception as e: + return self.response_400("Filter arguments not correct") # handle base order order_column, order_direction = self._handle_order_args(_args) # handle pagination @@ -953,6 +956,9 @@ def _handle_page_args(self, rison_args): """ page_index = rison_args.get('page', 0) page_size = rison_args.get('page_size', self.page_size) + if not isinstance(page_size, int) or not isinstance(page_index, int): + log.warning("Wrong page parameters") + return 0, self.page_size max_page_size = current_app.config.get('FAB_API_MAX_PAGE_SIZE') if page_size > max_page_size: page_size = max_page_size @@ -970,6 +976,8 @@ def _handle_order_args(self, rison_args): order_direction = rison_args.get('order_direction', '') if not order_column and self.base_order: order_column, order_direction = self.base_order + if order_column not in self.order_columns: + return '', '' return order_column, order_direction def _handle_filters_args(self, rison_args): From e06d1cc3ae0be779935ae21d5fe1642b009222d8 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 19:22:00 +0000 Subject: [PATCH 077/109] [api] New, Rison arguments sanitized by Json schemas --- docs/rest_api.rst | 25 +++++++- examples/base_api/app/api.py | 17 ++++++ flask_appbuilder/api/__init__.py | 71 +++++++++++++++-------- flask_appbuilder/api/schemas.py | 98 ++++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 flask_appbuilder/api/schemas.py diff --git a/docs/rest_api.rst b/docs/rest_api.rst index d8eaa86f38..1eb42b9ecf 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -132,7 +132,7 @@ so data can be translated back and forth without loss or guesswork:: ... @expose('/greeting3') - @rison + @rison() def greeting3(self, **kwargs): if 'name' in kwargs['rison']: return self.response( @@ -183,7 +183,7 @@ So to test our concept:: ... @expose('/risonjson') - @rison + @rison() def rison_json(self, **kwargs): return self.response(200, result=kwargs['rison']) @@ -219,6 +219,27 @@ If we send an invalid *Rison* argument we get an error:: "message": "Not valid rison argument" } +You can additionally pass a JSON schema to +validate your Rison arguments, this way you can implement a very strict API easily:: + + schema = { + "type": "object", + "properties": { + "name": { + "type": "integer" + } + } + } + ... + + @expose('/greeting4') + @rison(schema) + def greeting4(self, **kwargs): + return self.response( + 200, + message="Hello {}".format(kwargs['rison']['name']) + ) + Finally to properly handle all possible exceptions use the ``safe`` decorator, that will catch all uncaught exceptions for you and return a proper error response. You can enable or disable stack trace response using the diff --git a/examples/base_api/app/api.py b/examples/base_api/app/api.py index 3d792fd342..4fcbb1cc38 100644 --- a/examples/base_api/app/api.py +++ b/examples/base_api/app/api.py @@ -3,6 +3,15 @@ from flask_appbuilder.security.decorators import protect from . import appbuilder +schema = { + "type": "object", + "properties": { + "name": { + "type": "integer" + } + } +} + class MyFirstApi(BaseApi): @@ -28,6 +37,14 @@ def greeting3(self, **kwargs): ) return self.response_400(message="Please send your name") + @expose('/greeting4') + @rison(schema) + def greeting4(self, **kwargs): + return self.response( + 200, + message="Hello {}".format(kwargs['rison']['name']) + ) + @expose('/risonjson') @rison def rison_json(self, **kwargs): diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index ae48a30a34..5ea6e3624e 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -3,12 +3,14 @@ import functools import traceback import prison +from jsonschema import validate, ValidationError from sqlalchemy.exc import IntegrityError from marshmallow import ValidationError from flask import Blueprint, make_response, jsonify, request, current_app from werkzeug.exceptions import BadRequest from flask_babel import lazy_gettext as _ from .convert import Model2SchemaConverter +from .schemas import get_list_schema, get_item_schema, get_info_schema from ..security.decorators import permission_name, protect from .._compat import as_unicode from ..const import ( @@ -64,7 +66,7 @@ def wraps(self, *args, **kwargs): return functools.update_wrapper(wraps, f) -def rison(f): +def rison(schema=None): """ Use this decorator to parse URI *Rison* arguments to a python data structure, you're method gets the data @@ -73,22 +75,49 @@ def rison(f): class ExampleApi(BaseApi): @expose('/risonjson') - @rison + @rison() def rison_json(self, **kwargs): return self.response(200, result=kwargs['rison']) - """ - def wraps(self, *args, **kwargs): - value = request.args.get(API_URI_RIS_KEY, None) - kwargs['rison'] = dict() - if value: - try: - kwargs['rison'] = \ - prison.loads(value) - except prison.decoder.ParserException: - return self.response_400(message="Not valid rison argument") - return f(self, *args, **kwargs) - return functools.update_wrapper(wraps, f) + You can additionally pass a JSON schema to + validate Rison arguments:: + + schema = { + "type": "object", + "properties": { + "arg1": { + "type": "integer" + } + } + } + + class ExampleApi(BaseApi): + @expose('/risonjson') + @rison(schema) + def rison_json(self, **kwargs): + return self.response(200, result=kwargs['rison']) + + """ + def _rison(f): + def wraps(self, *args, **kwargs): + value = request.args.get(API_URI_RIS_KEY, None) + kwargs['rison'] = dict() + if value: + try: + kwargs['rison'] = \ + prison.loads(value) + except prison.decoder.ParserException: + return self.response_400(message="Not valid rison argument") + if schema: + try: + validate(instance=kwargs['rison'], schema=schema) + except ValidationError as e: + return self.response_400( + message="Not valid rison schema {}".format(e) + ) + return f(self, *args, **kwargs) + return functools.update_wrapper(wraps, f) + return _rison def expose(url='/', methods=('GET',)): @@ -719,7 +748,7 @@ def merge_search_filters(self, response, **kwargs): @expose('/_info', methods=['GET']) @protect() @safe - @rison + @rison(get_info_schema) @permission_name('info') @merge_response_func(BaseApi.merge_current_user_permissions, API_PERMISSIONS_RIS_KEY) @merge_response_func(merge_add_field_info, API_ADD_COLUMNS_RIS_KEY) @@ -863,7 +892,7 @@ def merge_order_columns(self, response, **kwargs): else: response[API_ORDER_COLUMNS_RES_KEY] = self.order_columns - @rison + @rison(get_item_schema) @merge_response_func(merge_label_columns, API_LABEL_COLUMNS_RIS_KEY) @merge_response_func(merge_show_columns, API_SHOW_COLUMNS_RIS_KEY) @merge_response_func(merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY) @@ -893,7 +922,7 @@ def _get_item(self, pk, **kwargs): _response[API_RESULT_RES_KEY] = _show_model_schema.dump(item, many=False).data return self.response(200, **_response) - @rison + @rison(get_list_schema) @merge_response_func(merge_order_columns, API_ORDER_COLUMNS_RIS_KEY) @merge_response_func(merge_label_columns, API_LABEL_COLUMNS_RIS_KEY) @merge_response_func(merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY) @@ -916,10 +945,7 @@ def _get_list(self, **kwargs): else: _list_model_schema = self.list_model_schema # handle filters - try: - joined_filters = self._handle_filters_args(_args) - except Exception as e: - return self.response_400("Filter arguments not correct") + joined_filters = self._handle_filters_args(_args) # handle base order order_column, order_direction = self._handle_order_args(_args) # handle pagination @@ -956,9 +982,6 @@ def _handle_page_args(self, rison_args): """ page_index = rison_args.get('page', 0) page_size = rison_args.get('page_size', self.page_size) - if not isinstance(page_size, int) or not isinstance(page_index, int): - log.warning("Wrong page parameters") - return 0, self.page_size max_page_size = current_app.config.get('FAB_API_MAX_PAGE_SIZE') if page_size > max_page_size: page_size = max_page_size diff --git a/flask_appbuilder/api/schemas.py b/flask_appbuilder/api/schemas.py new file mode 100644 index 0000000000..d2c3c2db74 --- /dev/null +++ b/flask_appbuilder/api/schemas.py @@ -0,0 +1,98 @@ + +get_list_schema = { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "list_columns", + "order_columns", + "label_columns", + "description_columns", + "none" + ] + } + }, + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "order_column": { + "type": "string" + }, + "order_direction": { + "type": "string", + "enum": ["asc", "desc"] + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "filters": { + "type": "array", + "items": { + "type": "object", + "properties": { + "col": { + "type": "string" + }, + "opr": { + "type": "string" + }, + "value": { + "type": ["number", "string", "boolean", "null"] + } + } + } + } + } +} + +get_item_schema = { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "show_columns", + "description_columns", + "label_columns", + "none" + ] + } + }, + "columns": { + "type": "array", + "items": { + "type": "string" + } + } + } +} + +get_info_schema = { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "add_columns", + "edit_columns", + "filters", + "permissions", + "none" + ] + } + } + } +} From 86b59acab5a052836c59eba3cc37608b66660232 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 19:34:22 +0000 Subject: [PATCH 078/109] [api] Fix, Rison arguments sanitized by Json schemas --- docs/rest_api.rst | 2 +- flask_appbuilder/api/__init__.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 1eb42b9ecf..ce57488afc 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -216,7 +216,7 @@ If we send an invalid *Rison* argument we get an error:: < Content-Type: application/json; charset=utf-8 ... { - "message": "Not valid rison argument" + "message": "Not a valid rison argument" } You can additionally pass a JSON schema to diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index 5ea6e3624e..c189fdf632 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -3,7 +3,7 @@ import functools import traceback import prison -from jsonschema import validate, ValidationError +import jsonschema from sqlalchemy.exc import IntegrityError from marshmallow import ValidationError from flask import Blueprint, make_response, jsonify, request, current_app @@ -107,13 +107,13 @@ def wraps(self, *args, **kwargs): kwargs['rison'] = \ prison.loads(value) except prison.decoder.ParserException: - return self.response_400(message="Not valid rison argument") + return self.response_400(message="Not a valid rison argument") if schema: try: - validate(instance=kwargs['rison'], schema=schema) - except ValidationError as e: + jsonschema.validate(instance=kwargs['rison'], schema=schema) + except jsonschema.ValidationError as e: return self.response_400( - message="Not valid rison schema {}".format(e) + message="Not a valid rison schema {}".format(e) ) return f(self, *args, **kwargs) return functools.update_wrapper(wraps, f) From 92144f8ba5604811342344d6564e9ed98b7ac493 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 19:37:59 +0000 Subject: [PATCH 079/109] [api] Fix, missing dependencies --- requirements.txt | 1 + rtd_requirements.txt | 1 + setup.py | 1 + 3 files changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index 51a98f3fef..7547b5a933 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,6 +31,7 @@ prison==0.1.0 pyasn1==0.4.5 pyasn1-modules==0.2.4 PyJWT==1.7.1 +jsonschema==3.0.1 pymongo==2.8.1 python-dateutil==2.8.0 pytz==2018.9 diff --git a/rtd_requirements.txt b/rtd_requirements.txt index fdd929fe5e..30c2de77d0 100644 --- a/rtd_requirements.txt +++ b/rtd_requirements.txt @@ -13,4 +13,5 @@ Flask-JWT-Extended>=3.18,<4 marshmallow==2.18.0 marshmallow-sqlalchemy==0.15.0 prison==0.1.0 +jsonschema==3.0.1 PyJWT>=1.7.1 diff --git a/setup.py b/setup.py index 22ec83947a..9f9b1e64c1 100644 --- a/setup.py +++ b/setup.py @@ -49,6 +49,7 @@ def desc(): 'marshmallow-enum>=1.4.1,<2' 'marshmallow-sqlalchemy>=0.16.1<1', 'prison==0.1.0', + 'jsonschema>=3.0.1<4', 'PyJWT>=1.7.1' ], tests_require=[ From fd34aedbd8b310212058c3d05df58a180e475228 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 29 Mar 2019 23:13:40 +0000 Subject: [PATCH 080/109] [api] New, titles meta data keys --- docs/rest_api.rst | 6 +++ examples/crud_rest_api/app/views.py | 2 + flask_appbuilder/api/__init__.py | 40 ++++++++++++++--- flask_appbuilder/api/schemas.py | 67 ++++++++++++++++++++--------- flask_appbuilder/const.py | 14 ++++++ flask_appbuilder/tests/test_api.py | 20 +++++++-- 6 files changed, 118 insertions(+), 31 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index ce57488afc..a3f1271d68 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -476,6 +476,7 @@ Now let's query our newly created Group:: { "description_columns": {}, + "show_title": "Show Contact Group", "show_columns": [ "name" ], @@ -544,6 +545,8 @@ First a birds eye view from the output of the **_info** endpoint:: { "add_columns": [...], "edit_columns": [...], + "add_title": "...", + "edit_title": "...", "filters": {...}, "permissions": [...] } @@ -719,6 +722,7 @@ The response data structure is:: "description_columnns": {}, "label_columns": {}, "show_columns": [], + "show_title": "", "result": {} } @@ -746,6 +750,7 @@ Our *curl* command will look like:: "name", "address" ], + "show_title": "Show Contact", "label_columns": { "address": "Address", "name": "Name" @@ -832,6 +837,7 @@ The response data structure is:: "label_columns": {}, "list_columns": [ ... An ordered list of columns ...], "order_columns": [ ... List of columns that can be ordered ... ], + "list_title": "", "result": {} } diff --git a/examples/crud_rest_api/app/views.py b/examples/crud_rest_api/app/views.py index 3b167873be..ac03447fa9 100644 --- a/examples/crud_rest_api/app/views.py +++ b/examples/crud_rest_api/app/views.py @@ -20,6 +20,7 @@ def fill_gender(): class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) + allow_browser_login = True appbuilder.add_api(ContactModelApi) @@ -28,6 +29,7 @@ class ContactModelApi(ModelRestApi): class GroupModelApi(ModelRestApi): resource_name = 'group' datamodel = SQLAInterface(ContactGroup) + allow_browser_login = True appbuilder.add_api(GroupModelApi) diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index c189fdf632..31c0bc93d6 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -34,7 +34,19 @@ API_EDIT_COLUMNS_RIS_KEY, API_SELECT_COLUMNS_RIS_KEY, API_FILTERS_RIS_KEY, - API_PERMISSIONS_RIS_KEY + API_PERMISSIONS_RIS_KEY, + API_ORDER_COLUMN_RIS_KEY, + API_ORDER_DIRECTION_RIS_KEY, + API_PAGE_INDEX_RIS_KEY, + API_PAGE_SIZE_RIS_KEY, + API_LIST_TITLE_RES_KEY, + API_ADD_TITLE_RES_KEY, + API_EDIT_TITLE_RES_KEY, + API_SHOW_TITLE_RES_KEY, + API_LIST_TITLE_RIS_KEY, + API_ADD_TITLE_RIS_KEY, + API_EDIT_TITLE_RIS_KEY, + API_SHOW_TITLE_RIS_KEY ) log = logging.getLogger(__name__) @@ -745,6 +757,12 @@ def merge_search_filters(self, response, **kwargs): ] response[API_FILTERS_RES_KEY] = search_filters + def merge_add_title(self, response, **kwargs): + response[API_ADD_TITLE_RES_KEY] = self.add_title + + def merge_edit_title(self, response, **kwargs): + response[API_EDIT_TITLE_RES_KEY] = self.edit_title + @expose('/_info', methods=['GET']) @protect() @safe @@ -754,6 +772,8 @@ def merge_search_filters(self, response, **kwargs): @merge_response_func(merge_add_field_info, API_ADD_COLUMNS_RIS_KEY) @merge_response_func(merge_edit_field_info, API_EDIT_COLUMNS_RIS_KEY) @merge_response_func(merge_search_filters, API_FILTERS_RIS_KEY) + @merge_response_func(merge_add_title, API_ADD_TITLE_RIS_KEY) + @merge_response_func(merge_edit_title, API_EDIT_TITLE_RIS_KEY) def info(self, **kwargs): """ Endpoint that renders a response for CRUD REST meta data @@ -892,10 +912,17 @@ def merge_order_columns(self, response, **kwargs): else: response[API_ORDER_COLUMNS_RES_KEY] = self.order_columns + def merge_list_title(self, response, **kwargs): + response[API_LIST_TITLE_RES_KEY] = self.list_title + + def merge_show_title(self, response, **kwargs): + response[API_SHOW_TITLE_RES_KEY] = self.show_title + @rison(get_item_schema) @merge_response_func(merge_label_columns, API_LABEL_COLUMNS_RIS_KEY) @merge_response_func(merge_show_columns, API_SHOW_COLUMNS_RIS_KEY) @merge_response_func(merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY) + @merge_response_func(merge_show_title, API_SHOW_TITLE_RIS_KEY) def _get_item(self, pk, **kwargs): item = self.datamodel.get(pk, self._base_filters) if not item: @@ -927,6 +954,7 @@ def _get_item(self, pk, **kwargs): @merge_response_func(merge_label_columns, API_LABEL_COLUMNS_RIS_KEY) @merge_response_func(merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY) @merge_response_func(merge_list_columns, API_LIST_COLUMNS_RIS_KEY) + @merge_response_func(merge_list_title, API_LIST_TITLE_RIS_KEY) def _get_list(self, **kwargs): _response = dict() _args = kwargs.get('rison', {}) @@ -980,10 +1008,10 @@ def _handle_page_args(self, rison_args): :param args: :return: (tuple) page, page_size """ - page_index = rison_args.get('page', 0) - page_size = rison_args.get('page_size', self.page_size) + page_index = rison_args.get(API_PAGE_INDEX_RIS_KEY, 0) + page_size = rison_args.get(API_PAGE_SIZE_RIS_KEY, self.page_size) max_page_size = current_app.config.get('FAB_API_MAX_PAGE_SIZE') - if page_size > max_page_size: + if page_size > max_page_size or page_size < 1: page_size = max_page_size return page_index, page_size @@ -995,8 +1023,8 @@ def _handle_order_args(self, rison_args): :param rison_args: :return: """ - order_column = rison_args.get('order_column', '') - order_direction = rison_args.get('order_direction', '') + order_column = rison_args.get(API_ORDER_COLUMN_RIS_KEY, '') + order_direction = rison_args.get(API_ORDER_DIRECTION_RIS_KEY, '') if not order_column and self.base_order: order_column, order_direction = self.base_order if order_column not in self.order_columns: diff --git a/flask_appbuilder/api/schemas.py b/flask_appbuilder/api/schemas.py index d2c3c2db74..5f2e88c4ef 100644 --- a/flask_appbuilder/api/schemas.py +++ b/flask_appbuilder/api/schemas.py @@ -1,40 +1,62 @@ +from ..const import ( + API_ORDER_COLUMNS_RIS_KEY, + API_LABEL_COLUMNS_RIS_KEY, + API_LIST_COLUMNS_RIS_KEY, + API_DESCRIPTION_COLUMNS_RIS_KEY, + API_SHOW_COLUMNS_RIS_KEY, + API_ADD_COLUMNS_RIS_KEY, + API_EDIT_COLUMNS_RIS_KEY, + API_SELECT_COLUMNS_RIS_KEY, + API_FILTERS_RIS_KEY, + API_PERMISSIONS_RIS_KEY, + API_SELECT_KEYS_RIS_KEY, + API_ORDER_COLUMN_RIS_KEY, + API_ORDER_DIRECTION_RIS_KEY, + API_PAGE_INDEX_RIS_KEY, + API_PAGE_SIZE_RIS_KEY, + API_LIST_TITLE_RIS_KEY, + API_ADD_TITLE_RIS_KEY, + API_EDIT_TITLE_RIS_KEY, + API_SHOW_TITLE_RIS_KEY +) get_list_schema = { "type": "object", "properties": { - "keys": { + API_SELECT_KEYS_RIS_KEY: { "type": "array", "items": { "type": "string", "enum": [ - "list_columns", - "order_columns", - "label_columns", - "description_columns", + API_LIST_COLUMNS_RIS_KEY, + API_ORDER_COLUMNS_RIS_KEY, + API_LABEL_COLUMNS_RIS_KEY, + API_DESCRIPTION_COLUMNS_RIS_KEY, + API_LIST_TITLE_RIS_KEY, "none" ] } }, - "columns": { + API_SELECT_COLUMNS_RIS_KEY: { "type": "array", "items": { "type": "string" } }, - "order_column": { + API_ORDER_COLUMN_RIS_KEY: { "type": "string" }, - "order_direction": { + API_ORDER_DIRECTION_RIS_KEY: { "type": "string", "enum": ["asc", "desc"] }, - "page": { + API_PAGE_INDEX_RIS_KEY: { "type": "integer" }, - "page_size": { + API_PAGE_SIZE_RIS_KEY: { "type": "integer" }, - "filters": { + API_FILTERS_RIS_KEY: { "type": "array", "items": { "type": "object", @@ -57,19 +79,20 @@ get_item_schema = { "type": "object", "properties": { - "keys": { + API_SELECT_KEYS_RIS_KEY: { "type": "array", "items": { "type": "string", "enum": [ - "show_columns", - "description_columns", - "label_columns", + API_SHOW_COLUMNS_RIS_KEY, + API_DESCRIPTION_COLUMNS_RIS_KEY, + API_LABEL_COLUMNS_RIS_KEY, + API_SHOW_TITLE_RIS_KEY, "none" ] } }, - "columns": { + API_SELECT_COLUMNS_RIS_KEY: { "type": "array", "items": { "type": "string" @@ -81,15 +104,17 @@ get_info_schema = { "type": "object", "properties": { - "keys": { + API_SELECT_KEYS_RIS_KEY: { "type": "array", "items": { "type": "string", "enum": [ - "add_columns", - "edit_columns", - "filters", - "permissions", + API_ADD_COLUMNS_RIS_KEY, + API_EDIT_COLUMNS_RIS_KEY, + API_FILTERS_RIS_KEY, + API_PERMISSIONS_RIS_KEY, + API_ADD_TITLE_RIS_KEY, + API_EDIT_TITLE_RIS_KEY, "none" ] } diff --git a/flask_appbuilder/const.py b/flask_appbuilder/const.py index ca7fa8ef87..3e0598caea 100644 --- a/flask_appbuilder/const.py +++ b/flask_appbuilder/const.py @@ -147,6 +147,11 @@ API_FILTERS_RES_KEY = 'filters' API_PERMISSIONS_RES_KEY = 'permissions' +API_LIST_TITLE_RES_KEY = 'list_title' +API_ADD_TITLE_RES_KEY = 'add_title' +API_EDIT_TITLE_RES_KEY = 'edit_title' +API_SHOW_TITLE_RES_KEY = 'show_title' + # Request Rison keys API_URI_RIS_KEY = 'q' @@ -161,3 +166,12 @@ API_PERMISSIONS_RIS_KEY = 'permissions' API_SELECT_COLUMNS_RIS_KEY = 'columns' API_SELECT_KEYS_RIS_KEY = 'keys' +API_ORDER_COLUMN_RIS_KEY = 'order_column' +API_ORDER_DIRECTION_RIS_KEY = 'order_direction' +API_PAGE_INDEX_RIS_KEY = 'page' +API_PAGE_SIZE_RIS_KEY = 'page_size' + +API_LIST_TITLE_RIS_KEY = 'list_title' +API_ADD_TITLE_RIS_KEY = 'add_title' +API_EDIT_TITLE_RIS_KEY = 'edit_title' +API_SHOW_TITLE_RIS_KEY = 'show_title' diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index caebac119c..2113aa80f7 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -37,7 +37,15 @@ API_SECURITY_PROVIDER_KEY, API_SECURITY_ACCESS_TOKEN_KEY, API_SECURITY_REFRESH_TOKEN_KEY, - API_SECURITY_VERSION + API_SECURITY_VERSION, + API_LIST_TITLE_RIS_KEY, + API_LIST_TITLE_RES_KEY, + API_SHOW_TITLE_RIS_KEY, + API_SHOW_TITLE_RES_KEY, + API_ADD_TITLE_RIS_KEY, + API_ADD_TITLE_RES_KEY, + API_EDIT_TITLE_RIS_KEY, + API_EDIT_TITLE_RES_KEY ) @@ -469,7 +477,8 @@ def test_get_item_select_meta_data(self): selectable_keys = [ API_DESCRIPTION_COLUMNS_RIS_KEY, API_LABEL_COLUMNS_RIS_KEY, - API_SHOW_COLUMNS_RIS_KEY + API_SHOW_COLUMNS_RIS_KEY, + API_SHOW_TITLE_RIS_KEY ] for selectable_key in selectable_keys: argument = { @@ -901,7 +910,8 @@ def test_get_list_select_meta_data(self): API_DESCRIPTION_COLUMNS_RIS_KEY, API_LABEL_COLUMNS_RIS_KEY, API_ORDER_COLUMNS_RIS_KEY, - API_LIST_COLUMNS_RIS_KEY + API_LIST_COLUMNS_RIS_KEY, + API_LIST_TITLE_RIS_KEY ] for selectable_key in selectable_keys: argument = { @@ -1189,7 +1199,9 @@ def test_info_select_meta_data(self): API_ADD_COLUMNS_RIS_KEY, API_EDIT_COLUMNS_RIS_KEY, API_PERMISSIONS_RIS_KEY, - API_FILTERS_RIS_KEY + API_FILTERS_RIS_KEY, + API_ADD_TITLE_RIS_KEY, + API_EDIT_TITLE_RIS_KEY ] for selectable_key in selectable_keys: arguments = { From 9d625cae8e49633f537f064cd553afa3aaba9cf9 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 1 Apr 2019 11:26:56 +0100 Subject: [PATCH 081/109] [api] New, pagination and ordering on related fields --- docs/rest_api.rst | 49 ++++++++++- flask_appbuilder/api/__init__.py | 137 +++++++++++++++++++++-------- flask_appbuilder/api/schemas.py | 14 +++ flask_appbuilder/tests/test_api.py | 11 ++- 4 files changed, 171 insertions(+), 40 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index a3f1271d68..38ad900e94 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -565,6 +565,7 @@ following data structure:: "unique": true|false, "type": "String|Integer|Related|RelatedList|...", "validate": [ ... list of validation methods ... ] + "count": "values" : [ ... optional with all possible values for a related field ... ] }, ... @@ -698,6 +699,34 @@ or ``edit_query_rel_fields``:: 'gender': [['name', FilterStartsWith, 'F']] } +You can also impose an order for these values server side using ``order_rel_fields``:: + + class ContactModelRestApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + order_rel_fields = { + 'contact_group': ('name', 'asc'), + 'gender': ('name', 'asc') + } + +Note that these related fields may render a long list of values, so pagination +is available and subject to a max page size. You can paginate these values using +the following Rison argument structure:: + + { + "add_columns": { + : { + 'page': int, + 'page_size': int + } + } + } + +Using Rison example:: + + (add_columns:(contact_group:(page:0,page_size:10))) + + The previous example will filter out only the **Female** gender from our list of possible values @@ -727,7 +756,7 @@ The response data structure is:: } Now we are going to cover the *Rison* arguments for custom fetching -meta data keys or columns. This time the accepted arguments is slightly extended:: +meta data keys or columns. This time the accepted arguments are slightly extended:: { "keys": [ ... List of meta data keys to return ... ], @@ -782,6 +811,24 @@ Our *curl* command will look like:: } } +To discard completely all meta data use the special key ``none``:: + + (columns:!(name,address),keys:!(none)) + +Our *curl* command will look like:: + + curl 'http://localhost:8080/api/v1/contact/1?q=(columns:!(name,address),keys:!(none))' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" + { + "id": "1", + "result": { + "address": "Street phoung", + "name": "Wilko Kamboh" + } + } + + We can restrict or add fields for the get item endpoint using the ``show_columns`` property. This takes precedence from the *Rison* arguments:: diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index 31c0bc93d6..684171ad16 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -6,6 +6,7 @@ import jsonschema from sqlalchemy.exc import IntegrityError from marshmallow import ValidationError +from marshmallow_sqlalchemy.fields import Related, RelatedList from flask import Blueprint, make_response, jsonify, request, current_app from werkzeug.exceptions import BadRequest from flask_babel import lazy_gettext as _ @@ -570,7 +571,7 @@ class ModelRestApi(BaseModelApi): """ order_columns = None """ Allowed order columns """ - page_size = 10 + page_size = 20 """ Use this property to change default page size """ @@ -597,7 +598,7 @@ class MyView(ModelView): Add a custom filter to form related fields:: class ContactModelView(ModelRestApi): - datamodel = SQLAModel(Contact, db.session) + datamodel = SQLAModel(Contact) add_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} """ @@ -615,6 +616,18 @@ class ContactModelView(ModelRestApi): edit_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} """ + order_rel_fields = None + """ + Impose order on related fields. + assign a dictionary where the keys are the related column names:: + + class ContactModelView(ModelRestApi): + datamodel = SQLAModel(Contact) + order_rel_fields = { + 'group': ('name', 'asc') + 'gender': ('name', 'asc') + } + """ list_model_schema = None """ Override to provide your own marshmallow Schema @@ -703,6 +716,7 @@ def _init_properties(self): self.show_exclude_columns = self.show_exclude_columns or [] self.add_exclude_columns = self.add_exclude_columns or [] self.edit_exclude_columns = self.edit_exclude_columns or [] + self.order_rel_fields = self.order_rel_fields or {} # Generate base props list_cols = self.datamodel.get_user_columns_list() if not self.list_columns and self.list_model_schema: @@ -731,19 +745,23 @@ def _init_properties(self): self.add_query_rel_fields = self.add_query_rel_fields or dict() def merge_add_field_info(self, response, **kwargs): + _kwargs = kwargs.get('add_columns', {}) response[API_ADD_COLUMNS_RES_KEY] = \ self._get_fields_info( self.add_columns, self.add_model_schema, - self.add_query_rel_fields + self.add_query_rel_fields, + **_kwargs ) def merge_edit_field_info(self, response, **kwargs): + _kwargs = kwargs.get('edit_columns', {}) response[API_EDIT_COLUMNS_RES_KEY] = \ self._get_fields_info( self.edit_columns, self.edit_model_schema, - self.edit_query_rel_fields + self.edit_query_rel_fields, + **_kwargs ) def merge_search_filters(self, response, **kwargs): @@ -781,7 +799,7 @@ def info(self, **kwargs): """ _response = dict() _args = kwargs.get('rison', {}) - self.set_response_key_mappings(_response, self.info, _args, **{}) + self.set_response_key_mappings(_response, self.info, _args, **_args) return self.response(200, **_response) @expose('/', methods=['GET']) @@ -1008,12 +1026,17 @@ def _handle_page_args(self, rison_args): :param args: :return: (tuple) page, page_size """ - page_index = rison_args.get(API_PAGE_INDEX_RIS_KEY, 0) + page = rison_args.get(API_PAGE_INDEX_RIS_KEY, 0) page_size = rison_args.get(API_PAGE_SIZE_RIS_KEY, self.page_size) + return self._sanitize_page_args(page, page_size) + + def _sanitize_page_args(self, page, page_size): + _page = page or 0 + _page_size = page_size or self.page_size max_page_size = current_app.config.get('FAB_API_MAX_PAGE_SIZE') - if page_size > max_page_size or page_size < 1: - page_size = max_page_size - return page_index, page_size + if _page_size > max_page_size or _page_size < 1: + _page_size = max_page_size + return _page, _page_size def _handle_order_args(self, rison_args): """ @@ -1047,7 +1070,7 @@ def _description_columns_json(self, cols=None): ret[key] = as_unicode(_(value).encode('UTF-8')) return ret - def _get_field_info(self, field, filter_rel_field): + def _get_field_info(self, field, filter_rel_field, page=None, page_size=None): """ Return a dict with field details ready to serve as a response @@ -1055,31 +1078,19 @@ def _get_field_info(self, field, filter_rel_field): :param field: marshmallow field :return: dict with field details """ - from marshmallow_sqlalchemy.fields import Related, RelatedList ret = dict() ret['name'] = field.name ret['label'] = self.label_columns.get(field.name, '') ret['description'] = self.description_columns.get(field.name, '') # Handles related fields if isinstance(field, Related) or isinstance(field, RelatedList): - _rel_interface = self.datamodel.get_related_interface(field.name) - _filters = _rel_interface.get_filters( - _rel_interface.get_search_columns_list() - ) - if filter_rel_field: - filters = _filters.add_filter_list(filter_rel_field) - _values = _rel_interface.query(filters)[1] - else: - _values = _rel_interface.query()[1] - ret['values'] = list() - for _value in _values: - ret['values'].append( - { - "id": _rel_interface.get_pk_value(_value), - "value": str(_value) - } - ) + ret['count'], ret['values'] = self._get_list_related_field( + field, + filter_rel_field, + page=page, + page_size=page_size + ) if field.validate and isinstance(field.validate, list): ret['validate'] = [str(v) for v in field.validate] elif field.validate: @@ -1089,7 +1100,7 @@ def _get_field_info(self, field, filter_rel_field): ret['unique'] = field.unique return ret - def _get_fields_info(self, cols, model_schema, filter_rel_fields): + def _get_fields_info(self, cols, model_schema, filter_rel_fields, **kwargs): """ Returns a dict with fields detail from a marshmallow schema @@ -1098,20 +1109,76 @@ def _get_fields_info(self, cols, model_schema, filter_rel_fields): :param model_schema: Marshmallow model schema :param filter_rel_fields: expects add_query_rel_fields or edit_query_rel_fields + :param kwargs: Receives all rison arguments for pagination :return: dict with all fields details """ - return [ - self._get_field_info( + ret = list() + for col in cols: + page = page_size = None + col_args = kwargs.get(col, {}) + if col_args: + page = col_args.get(API_PAGE_INDEX_RIS_KEY, None) + page_size = col_args.get(API_PAGE_SIZE_RIS_KEY, None) + ret.append(self._get_field_info( model_schema.fields[col], - filter_rel_fields.get(col, []) + filter_rel_fields.get(col, []), + page=page, + page_size=page_size + )) + return ret + + def _get_list_related_field(self, field, filter_rel_field, page=None, page_size=None): + """ + Return a list of values for a related field + + :param field: Marshmallow field + :param filter_rel_field: Filters for the related field + :param page: The page index + :param page_size: The page size + :return: (int, list) total record count and list of dict with id and value + """ + ret = list() + if isinstance(field, Related) or isinstance(field, RelatedList): + datamodel = self.datamodel.get_related_interface(field.name) + filters = datamodel.get_filters( + datamodel.get_search_columns_list() ) - for col in cols - ] + page, page_size = self._sanitize_page_args(page, page_size) + order_field = self.order_rel_fields.get(field.name) + if order_field: + order_column, order_direction = order_field + else: + order_column, order_direction = '', '' + if filter_rel_field: + filters = filters.add_filter_list(filter_rel_field) + count, values = datamodel.query( + filters, + order_column, + order_direction, + page=page, + page_size=page_size, + ) + else: + count, values = datamodel.query( + filters, + order_column, + order_direction, + page=page, + page_size=page_size, + ) + for value in values: + ret.append( + { + "id": datamodel.get_pk_value(value), + "value": str(value) + } + ) + return count, ret def _merge_update_item(self, model_item, data): """ Merge a model with a python data structure - This is useful to turn PUT method into a PATH also + This is useful to turn PUT method into a PATCH also :param model_item: SQLA Model :param data: python data structure :return: python data structure diff --git a/flask_appbuilder/api/schemas.py b/flask_appbuilder/api/schemas.py index 5f2e88c4ef..ff5f13932f 100644 --- a/flask_appbuilder/api/schemas.py +++ b/flask_appbuilder/api/schemas.py @@ -118,6 +118,20 @@ "none" ] } + }, + API_ADD_COLUMNS_RIS_KEY: { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + API_PAGE_SIZE_RIS_KEY: { + "type": "integer" + }, + API_PAGE_INDEX_RIS_KEY: { + "type": "integer" + } + } + } } } } diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 2113aa80f7..f714f22e1b 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -51,11 +51,11 @@ log = logging.getLogger(__name__) -MODEL1_DATA_SIZE = 20 -MODEL2_DATA_SIZE = 20 +MODEL1_DATA_SIZE = 30 +MODEL2_DATA_SIZE = 30 USERNAME = "testadmin" PASSWORD = "password" -MAX_PAGE_SIZE = 10 +MAX_PAGE_SIZE = 25 class FlaskTestCase(unittest.TestCase): @@ -813,6 +813,7 @@ def test_get_list_max_page_size(self): API_URI_RIS_KEY, prison.dumps(arguments) ) + print("URI {}".format(uri)) rv = self.auth_client_get( client, token, @@ -1099,6 +1100,7 @@ def test_info_fields_rel_field(self): ) data = json.loads(rv.data.decode('utf-8')) expected_rel_add_field = { + 'count': MODEL2_DATA_SIZE, 'description': '', 'label': 'Group', 'name': 'group', @@ -1107,7 +1109,7 @@ def test_info_fields_rel_field(self): 'type': 'Related', 'values': [] } - for i in range(MODEL1_DATA_SIZE): + for i in range(self.model2api.page_size): expected_rel_add_field['values'].append( { 'id': i + 1, @@ -1139,6 +1141,7 @@ def test_info_fields_rel_filtered_field(self): 'required': True, 'unique': False, 'type': 'Related', + 'count': 1, 'values': [ { 'id': 4, From 91d28239ce971ff3ca2b97b3908ef4acc0fc76ba Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 1 Apr 2019 11:59:12 +0100 Subject: [PATCH 082/109] [api] [docs] Fix missing new count key on related fields info endpoint --- docs/rest_api.rst | 7 ++++--- examples/quickhowto/app/views.py | 12 ++++++++++++ examples/quickhowto/config.py | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 38ad900e94..89896bcc85 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -674,6 +674,7 @@ values from related fields, using our *quickhowto* example:: "required": false, "unique": false, "type": "Related", + "count": 2, "values": [ { "id": 1, @@ -709,6 +710,9 @@ You can also impose an order for these values server side using ``order_rel_fiel 'gender': ('name', 'asc') } +The previous example will filter out only the **Female** gender from our list +of possible values + Note that these related fields may render a long list of values, so pagination is available and subject to a max page size. You can paginate these values using the following Rison argument structure:: @@ -727,9 +731,6 @@ Using Rison example:: (add_columns:(contact_group:(page:0,page_size:10))) -The previous example will filter out only the **Female** gender from our list -of possible values - We can also restrict server side the available fields for add and edit using ``add_columns`` and ``edit_columns``. Additionally you can use ``add_exclude_columns`` and ``edit_exclude_columns``:: diff --git a/examples/quickhowto/app/views.py b/examples/quickhowto/app/views.py index 3c9d58f60e..bd5823a5e1 100644 --- a/examples/quickhowto/app/views.py +++ b/examples/quickhowto/app/views.py @@ -107,6 +107,18 @@ class ContactTimeChartView(GroupByChartView): ] +from flask_appbuilder import ModelRestApi + +class ContactModelApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + allow_browser_login = True + order_rel_fields = { + 'contact_group': ('name', 'desc') + } + +appbuilder.add_api(ContactModelApi) + db.create_all() fill_gender() appbuilder.add_view( diff --git a/examples/quickhowto/config.py b/examples/quickhowto/config.py index bba522f789..10755fca44 100644 --- a/examples/quickhowto/config.py +++ b/examples/quickhowto/config.py @@ -34,7 +34,7 @@ 'ja_JP': {'flag': 'jp', 'name': 'Japanese'} } - +FAB_API_MAX_PAGE_SIZE=100 #------------------------------ # GLOBALS FOR GENERAL APP's #------------------------------ From 4a7f643e54c136d6136f998de2cd2925791019f7 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 1 Apr 2019 14:37:29 +0100 Subject: [PATCH 083/109] [api] [docs] New, Enum fields support --- docs/rest_api.rst | 23 +++++++++++++++++++++++ examples/quickhowto/app/views.py | 12 ------------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 89896bcc85..4162633e4d 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -1152,3 +1152,26 @@ a simpler way of doing this using ``validators_columns`` property:: validators_columns = {'name': validate_name} +Enum Fields +----------- + +``ModelRestApi`` offers support for **Enum** fields, you have to declare them +on a specific way:: + + class GenderEnum(enum.Enum): + male = 'Male' + female = 'Female' + + + class Contact(Model): + id = Column(Integer, primary_key=True) + name = Column(String(150), unique=True, nullable=False) + address = Column(String(564)) + birthday = Column(Date, nullable=True) + personal_phone = Column(String(20)) + personal_celphone = Column(String(20)) + contact_group_id = Column(Integer, ForeignKey('contact_group.id'), nullable=False) + contact_group = relationship("ContactGroup") + gender = Column(Enum(GenderEnum), nullable=False, info={"enum_class": GenderEnum}) + +Notice the ``info={"enum_class": GenderEnum}`` diff --git a/examples/quickhowto/app/views.py b/examples/quickhowto/app/views.py index bd5823a5e1..3c9d58f60e 100644 --- a/examples/quickhowto/app/views.py +++ b/examples/quickhowto/app/views.py @@ -107,18 +107,6 @@ class ContactTimeChartView(GroupByChartView): ] -from flask_appbuilder import ModelRestApi - -class ContactModelApi(ModelRestApi): - resource_name = 'contact' - datamodel = SQLAInterface(Contact) - allow_browser_login = True - order_rel_fields = { - 'contact_group': ('name', 'desc') - } - -appbuilder.add_api(ContactModelApi) - db.create_all() fill_gender() appbuilder.add_view( From 463b63e22c7a6d63789618c592f7b57afd5e7484 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 3 Apr 2019 15:18:54 +0100 Subject: [PATCH 084/109] [api] [docs] Fix, typo --- flask_appbuilder/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index 684171ad16..2ab94e93d9 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -82,7 +82,7 @@ def wraps(self, *args, **kwargs): def rison(schema=None): """ Use this decorator to parse URI *Rison* arguments to - a python data structure, you're method gets the data + a python data structure, your method gets the data structure on kwargs['rison']. Response is HTTP 400 if *Rison* is not correct:: From ab210aeb2a89abc1286e2877f3066ed1bbd96469 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 3 Apr 2019 15:28:57 +0100 Subject: [PATCH 085/109] [api] [docs] Fix, typo --- flask_appbuilder/api/__init__.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index 2ab94e93d9..bdbc2ff7c0 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -68,6 +68,7 @@ def safe(f): A decorator that catches uncaught exceptions and return the response in JSON format (inspired on Superset code) """ + def wraps(self, *args, **kwargs): try: return f(self, *args, **kwargs) @@ -76,6 +77,7 @@ def wraps(self, *args, **kwargs): except Exception as e: logging.exception(e) return self.response_500(message=get_error_msg()) + return functools.update_wrapper(wraps, f) @@ -111,6 +113,7 @@ def rison_json(self, **kwargs): return self.response(200, result=kwargs['rison']) """ + def _rison(f): def wraps(self, *args, **kwargs): value = request.args.get(API_URI_RIS_KEY, None) @@ -129,7 +132,9 @@ def wraps(self, *args, **kwargs): message="Not a valid rison schema {}".format(e) ) return f(self, *args, **kwargs) + return functools.update_wrapper(wraps, f) + return _rison @@ -148,6 +153,7 @@ def wrap(f): f._urls = [] f._urls.append((url, methods)) return f + return wrap @@ -162,15 +168,17 @@ def merge_response_func(func, key): def merge_some_function(self, response, rison_args): ``` - :param func: Name of the merge function where the key is allower + :param func: Name of the merge function where the key is allowed :param key: The key name for rison selection :return: None """ + def wrap(f): if not hasattr(f, '_response_key_func_mappings'): f._response_key_func_mappings = dict() f._response_key_func_mappings[key] = func return f + return wrap @@ -181,7 +189,7 @@ class BaseApi(object): as a Blueprint. This class does not expose any urls, - but provides a common base for all apis. + but provides a common base for all APIS. """ appbuilder = None @@ -458,6 +466,7 @@ class MyView(ModelRestApi): Filters object will calculate all possible filter types based on search_columns """ + def __init__(self, **kwargs): """ Constructor @@ -729,7 +738,9 @@ def _init_properties(self): self._gen_labels_columns(self.list_columns) self.order_columns = self.order_columns or \ - self.datamodel.get_order_columns_list(list_columns=self.list_columns) + self.datamodel.get_order_columns_list( + list_columns=self.list_columns + ) # Process excluded columns if not self.show_columns: self.show_columns = \ @@ -1017,6 +1028,7 @@ def _get_list(self, **kwargs): HELPER FUNCTIONS ------------------------------------------------ """ + def _handle_page_args(self, rison_args): """ Helper function to handle rison page @@ -1194,6 +1206,7 @@ def _merge_update_item(self, model_item, data): PRE AND POST METHODS ------------------------------------------------ """ + def pre_update(self, item): """ Override this, this method is called before the update takes place. From 0ce53006d69e93aa432846b8582686651dc6fae3 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 3 Apr 2019 15:45:37 +0100 Subject: [PATCH 086/109] [api] [examples] Fix, removed REACT app for now, not working with auth --- examples/quickhowto-react/README.md | 4 - examples/quickhowto-react/package.json | 32 -------- examples/quickhowto-react/public/favicon.ico | Bin 3870 -> 0 bytes examples/quickhowto-react/public/index.html | 13 ---- .../quickhowto-react/public/manifest.json | 15 ---- examples/quickhowto-react/src/App.js | 23 ------ examples/quickhowto-react/src/api/Api.js | 18 ----- .../quickhowto-react/src/components/Table.js | 73 ------------------ examples/quickhowto-react/src/index.js | 5 -- 9 files changed, 183 deletions(-) delete mode 100644 examples/quickhowto-react/README.md delete mode 100644 examples/quickhowto-react/package.json delete mode 100644 examples/quickhowto-react/public/favicon.ico delete mode 100644 examples/quickhowto-react/public/index.html delete mode 100644 examples/quickhowto-react/public/manifest.json delete mode 100644 examples/quickhowto-react/src/App.js delete mode 100644 examples/quickhowto-react/src/api/Api.js delete mode 100644 examples/quickhowto-react/src/components/Table.js delete mode 100644 examples/quickhowto-react/src/index.js diff --git a/examples/quickhowto-react/README.md b/examples/quickhowto-react/README.md deleted file mode 100644 index de99bde051..0000000000 --- a/examples/quickhowto-react/README.md +++ /dev/null @@ -1,4 +0,0 @@ -Use this React app with the quickhowto example. -quickhowto provides the REST api that the React app will consume - - diff --git a/examples/quickhowto-react/package.json b/examples/quickhowto-react/package.json deleted file mode 100644 index 14daad5a12..0000000000 --- a/examples/quickhowto-react/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "react-tutorial", - "version": "1.0.0", - "private": true, - "dependencies": { - "axios": "^0.18.0", - "react": "^16.4.1", - "react-dom": "^16.4.1", - "react-scripts": "^2.1.1", - "react-router-dom": "^4.3.1", - "react-icons": "^3.3.0", - "bootstrap": "^4.2.1" - }, - "homepage": "https://taniarascia.github.io/react-tutorial", - "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject", - "predeploy": "npm run build", - "deploy": "gh-pages -d build" - }, - "devDependencies": { - "gh-pages": "^1.2.0" - }, - "browserslist": [ - ">0.2%", - "not dead", - "not ie <= 11", - "not op_mini all" - ] -} diff --git a/examples/quickhowto-react/public/favicon.ico b/examples/quickhowto-react/public/favicon.ico deleted file mode 100644 index a11777cc471a4344702741ab1c8a588998b1311a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ diff --git a/examples/quickhowto-react/public/index.html b/examples/quickhowto-react/public/index.html deleted file mode 100644 index 90e879fb1d..0000000000 --- a/examples/quickhowto-react/public/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - React App - - - -
- - diff --git a/examples/quickhowto-react/public/manifest.json b/examples/quickhowto-react/public/manifest.json deleted file mode 100644 index ef19ec243e..0000000000 --- a/examples/quickhowto-react/public/manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "short_name": "React App", - "name": "Create React App Sample", - "icons": [ - { - "src": "favicon.ico", - "sizes": "64x64 32x32 24x24 16x16", - "type": "image/x-icon" - } - ], - "start_url": "./index.html", - "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" -} diff --git a/examples/quickhowto-react/src/App.js b/examples/quickhowto-react/src/App.js deleted file mode 100644 index 814fa409c5..0000000000 --- a/examples/quickhowto-react/src/App.js +++ /dev/null @@ -1,23 +0,0 @@ -import React, { Component } from 'react'; -import '../node_modules/bootstrap/dist/css/bootstrap.min.css'; -import Table from './components/Table'; -import axios from 'axios'; - - -class App extends Component { - - - render() { - return ( -
-

React FAB2 Experiment

-
-
- - ); - } -} - -export default App; diff --git a/examples/quickhowto-react/src/api/Api.js b/examples/quickhowto-react/src/api/Api.js deleted file mode 100644 index 4a3aa2cd04..0000000000 --- a/examples/quickhowto-react/src/api/Api.js +++ /dev/null @@ -1,18 +0,0 @@ -import axios from 'axios' - - -const apiVersion = 'v1'; -const apiUrl = `http://localhost:8080/api/${apiVersion}`; - -class Api { - - constructor(height, width) { - this.client = axios.create({baseURL: apiUrl}); - } - - get(resource, filters=[], order={}, pageSize, page) { - return this.client.get(resource) - } -} - -export default Api diff --git a/examples/quickhowto-react/src/components/Table.js b/examples/quickhowto-react/src/components/Table.js deleted file mode 100644 index 49bc787c56..0000000000 --- a/examples/quickhowto-react/src/components/Table.js +++ /dev/null @@ -1,73 +0,0 @@ -import React, { Component } from 'react'; -import { FaAngleUp } from "react-icons/fa"; -import { IconContext } from "react-icons"; -import Api from '../api/Api'; - - -class TableHeader extends Component { - - render() { - const row = this.props.listColumns.map(key => - {this.props.labelColumns[key]}) - return ( - {row} - ); - } -} - -class TableRow extends Component { - - render () { - const row = this.props.listColumns.map(key => - {this.props.obj[key]}) - return ( - {row} - ); - } -} - -class Table extends Component { - - constructor(props) { - super(props); - this.api = new Api(); - this.state = {data: [], - listColumns: [], - labelColumns: []}; - } - - componentDidMount(){ - this.api.get(this.props.resource) - .then(response => { - this.setState({ data: response.data.result, - listColumns: response.data.list_columns, - labelColumns: response.data.label_columns}); - }) - .catch(function (error) { - console.log(error); - }) - } - - rows(){ - const listColumns = this.state.listColumns - return this.state.data.map(function(object, i){ - return ; - }); - } - - render() { - return ( - - - - {this.rows()} - -
- ); - } -} - -export default Table; diff --git a/examples/quickhowto-react/src/index.js b/examples/quickhowto-react/src/index.js deleted file mode 100644 index dbe51b5a81..0000000000 --- a/examples/quickhowto-react/src/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import App from './App'; - -ReactDOM.render(, document.getElementById('root')); \ No newline at end of file From a13992cb861128221b2ff3a0b50caa76f73e1549 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 4 Apr 2019 09:46:14 +0100 Subject: [PATCH 087/109] [cli] Fix, typo --- flask_appbuilder/console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_appbuilder/console.py b/flask_appbuilder/console.py index 8e32c963bb..546e5feb2d 100644 --- a/flask_appbuilder/console.py +++ b/flask_appbuilder/console.py @@ -35,7 +35,7 @@ def import_application(app_package, appbuilder): if hasattr(_app, appbuilder): return getattr(_app, appbuilder) else: - click.echo(click.style('There in no appbuilder var on your package, you can use appbuilder parameter to config', fg='red')) + click.echo(click.style('There is no appbuilder var on your package, you can use appbuilder parameter to config', fg='red')) exit(3) From fa6a8d36289ee9774975f847ed7a27e5ec2ccbbd Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 4 Apr 2019 09:46:50 +0100 Subject: [PATCH 088/109] [ci] Fix, missing marshmallow-enum on the requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 7547b5a933..ef932a8cc4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,7 @@ Jinja2==2.10 MarkupSafe==1.1.1 marshmallow==2.18.0 marshmallow-sqlalchemy==0.15.0 +marshmallow-enum==1.4.1 mockldap==0.3.0 mongoengine==0.7.10 nose==1.3.7 From 704d43463ca51d9c74cf070e44235fea9cea6bc0 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 5 Apr 2019 14:28:32 +0100 Subject: [PATCH 089/109] [api] New, global OpenApi spec --- flask_appbuilder/api/__init__.py | 514 ++++++++++++++++++++++++------- flask_appbuilder/base.py | 14 +- flask_appbuilder/security/api.py | 78 ++++- setup.py | 2 +- 4 files changed, 482 insertions(+), 126 deletions(-) diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index bdbc2ff7c0..e30aee023b 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -4,6 +4,7 @@ import traceback import prison import jsonschema +from apispec.exceptions import DuplicateComponentNameError from sqlalchemy.exc import IntegrityError from marshmallow import ValidationError from marshmallow_sqlalchemy.fields import Related, RelatedList @@ -132,9 +133,7 @@ def wraps(self, *args, **kwargs): message="Not a valid rison schema {}".format(e) ) return f(self, *args, **kwargs) - return functools.update_wrapper(wraps, f) - return _rison @@ -224,6 +223,27 @@ class ExampleApi(BaseApi): """ extra_args = None + apispec_parameter_schemas = None + """ + Set your custom Rison parameter schemas here so that + they get registered on the OpenApi spec:: + + custom_parameter = { + "type": "object" + "properties": { + "name": { + "type": "string" + } + } + } + + class CustomApi(BaseApi): + apispec_parameter_schemas = { + "custom_parameter": custom_parameter + } + """ + _apispec_parameter_schemas = None + def __init__(self): """ Initialization of base permissions @@ -232,6 +252,9 @@ def __init__(self): Initialization of extra args """ self._response_key_func_mappings = dict() + self.apispec_parameter_schemas = self.apispec_parameter_schemas or dict() + self._apispec_parameter_schemas = self._apispec_parameter_schemas or dict() + self._apispec_parameter_schemas.update(self.apispec_parameter_schemas) if self.base_permissions is None: self.base_permissions = set() for attr_name in dir(self): @@ -265,8 +288,12 @@ def create_blueprint(self, appbuilder, url_prefix=self.route_base) self._register_urls() + self.add_apispec_components() return self.blueprint + def add_apispec_components(self): + pass + def _register_urls(self): for attr_name in dir(self): attr = getattr(self, attr_name) @@ -278,6 +305,58 @@ def _register_urls(self): attr, methods=methods ) + operations = dict() + path = self.path_helper(path=url, operations=operations) + self.operation_helper( + path=path, + operations=operations, + methods=methods, + func=attr + ) + self.appbuilder.apispec.path( + path=path, + operations=operations + ) + + def path_helper(self, path=None, operations=None, **kwargs): + """ + Works like a apispec plugin + May return a path as string and mutate operations dict. + + :param str path: Path to the resource + :param dict operations: A `dict` mapping HTTP methods to operation object. See + https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject + :param kwargs: + :return: Return value should be a string or None. If a string is returned, it + is set as the path. + """ + RE_URL = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>') + path = RE_URL.sub(r'{\1}', path) + return "{}{}".format(self.blueprint.url_prefix, path) + + def operation_helper( + self, path=None, + operations=None, + methods=None, + func=None, + **kwargs): + """May mutate operations. + :param str path: Path to the resource + :param dict operations: A `dict` mapping HTTP methods to operation object. See + :param list methods: A list of methods registered for this path + """ + import yaml + from apispec import yaml_utils + + for method in methods: + yaml_doc_string = yaml_utils.load_operations_from_docstring(func.__doc__) + yaml_doc_string = yaml.safe_load(str(yaml_doc_string).replace( + "{{self.__class__.__name__}}", + self.__class__.__name__)) + if yaml_doc_string: + operations[method.lower()] = yaml_doc_string.get(method.lower(), {}) + else: + operations[method.lower()] = {} @staticmethod def _prettify_name(name): @@ -662,6 +741,11 @@ class ContactModelView(ModelRestApi): Override to use your own Model2SchemaConverter (inherit from BaseModel2SchemaConverter) """ + _apispec_parameter_schemas = { + "get_info_schema": get_info_schema, + "get_item_schema": get_item_schema, + "get_list_schema": get_list_schema + } def __init__(self): super(ModelRestApi, self).__init__() @@ -679,6 +763,30 @@ def create_blueprint(self, appbuilder, *args, **kwargs): **kwargs ) + def add_apispec_components(self): + super(ModelRestApi, self).add_apispec_components() + for k, v in self._apispec_parameter_schemas.items(): + try: + self.appbuilder.apispec.components.parameter(k, v) + except DuplicateComponentNameError: + continue + self.appbuilder.apispec.components.schema( + "{}.{}".format(self.__class__.__name__, "get_list"), + schema=self.list_model_schema + ) + self.appbuilder.apispec.components.schema( + "{}.{}".format(self.__class__.__name__, "post"), + schema=self.add_model_schema + ) + self.appbuilder.apispec.components.schema( + "{}.{}".format(self.__class__.__name__, "put"), + schema=self.edit_model_schema + ) + self.appbuilder.apispec.components.schema( + "{}.{}".format(self.__class__.__name__, "get_item"), + schema=self.show_model_schema + ) + def _init_model_schemas(self): # Create Marshmalow schemas if one is not specified if self.list_model_schema is None: @@ -686,7 +794,11 @@ def _init_model_schemas(self): self.model2schemaconverter.convert(self.list_columns) if self.add_model_schema is None: self.add_model_schema = \ - self.model2schemaconverter.convert(self.add_columns, nested=False) + self.model2schemaconverter.convert( + self.add_columns, + nested=False, + enum_dump_by_name=True + ) if self.edit_model_schema is None: self.edit_model_schema = \ self.model2schemaconverter.convert( @@ -792,114 +904,6 @@ def merge_add_title(self, response, **kwargs): def merge_edit_title(self, response, **kwargs): response[API_EDIT_TITLE_RES_KEY] = self.edit_title - @expose('/_info', methods=['GET']) - @protect() - @safe - @rison(get_info_schema) - @permission_name('info') - @merge_response_func(BaseApi.merge_current_user_permissions, API_PERMISSIONS_RIS_KEY) - @merge_response_func(merge_add_field_info, API_ADD_COLUMNS_RIS_KEY) - @merge_response_func(merge_edit_field_info, API_EDIT_COLUMNS_RIS_KEY) - @merge_response_func(merge_search_filters, API_FILTERS_RIS_KEY) - @merge_response_func(merge_add_title, API_ADD_TITLE_RIS_KEY) - @merge_response_func(merge_edit_title, API_EDIT_TITLE_RIS_KEY) - def info(self, **kwargs): - """ - Endpoint that renders a response for CRUD REST meta data - :param kwargs: Rison kwargs - """ - _response = dict() - _args = kwargs.get('rison', {}) - self.set_response_key_mappings(_response, self.info, _args, **_args) - return self.response(200, **_response) - - @expose('/', methods=['GET']) - @expose('/', methods=['GET']) - @protect() - @safe - @permission_name('get') - def get(self, pk=None): - if not pk: - return self._get_list() - return self._get_item(pk) - - @expose('/', methods=['POST']) - @protect() - @safe - @permission_name('post') - def post(self): - if not request.is_json: - return self.response_400(message='Request is not JSON') - try: - item = self.add_model_schema.load(request.json) - except ValidationError as err: - return self.response_422(message=err.messages) - # This validates custom Schema with custom validations - if isinstance(item.data, dict): - return self.response_422(message=item.errors) - self.pre_add(item.data) - try: - self.datamodel.add(item.data, raise_exception=True) - self.post_add(item.data) - return self.response( - 201, - **{ - API_RESULT_RES_KEY: self.add_model_schema.dump( - item.data, many=False - ).data, - 'id': self.datamodel.get_pk_value(item.data) - } - ) - except IntegrityError as e: - return self.response_422(message=str(e.orig)) - - @expose('/', methods=['PUT']) - @protect() - @safe - @permission_name('put') - def put(self, pk): - item = self.datamodel.get(pk, self._base_filters) - if not request.is_json: - return self.response(400, **{'message': 'Request is not JSON'}) - if not item: - return self.response_404() - try: - data = self._merge_update_item(item, request.json) - item = self.edit_model_schema.load(data, instance=item) - except ValidationError as err: - return self.response_422(message=err.messages) - # This validates custom Schema with custom validations - if isinstance(item.data, dict): - return self.response_422(message=item.errors) - self.pre_update(item.data) - try: - self.datamodel.edit(item.data, raise_exception=True) - self.post_update(item) - return self.response( - 200, - **{API_RESULT_RES_KEY: self.edit_model_schema.dump( - item.data, - many=False).data} - ) - except IntegrityError as e: - return self.response_422(message=str(e.orig)) - - @expose('/', methods=['DELETE']) - @protect() - @safe - @permission_name('delete') - def delete(self, pk): - item = self.datamodel.get(pk, self._base_filters) - if not item: - return self.response_404() - self.pre_delete(item) - try: - self.datamodel.delete(item, raise_exception=True) - self.post_delete(item) - return self.response(200, message='OK') - except IntegrityError as e: - return self.response_422(message=str(e.orig)) - def merge_label_columns(self, response, **kwargs): _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) if _pruned_select_cols: @@ -947,12 +951,87 @@ def merge_list_title(self, response, **kwargs): def merge_show_title(self, response, **kwargs): response[API_SHOW_TITLE_RES_KEY] = self.show_title + @expose('/_info', methods=['GET']) + @protect() + @safe + @rison(get_info_schema) + @permission_name('info') + @merge_response_func(BaseApi.merge_current_user_permissions, API_PERMISSIONS_RIS_KEY) + @merge_response_func(merge_add_field_info, API_ADD_COLUMNS_RIS_KEY) + @merge_response_func(merge_edit_field_info, API_EDIT_COLUMNS_RIS_KEY) + @merge_response_func(merge_search_filters, API_FILTERS_RIS_KEY) + @merge_response_func(merge_add_title, API_ADD_TITLE_RIS_KEY) + @merge_response_func(merge_edit_title, API_EDIT_TITLE_RIS_KEY) + def info(self, **kwargs): + """ + Endpoint that renders a response for CRUD REST meta data + :param kwargs: Rison kwargs + """ + _response = dict() + _args = kwargs.get('rison', {}) + self.set_response_key_mappings(_response, self.info, _args, **_args) + return self.response(200, **_response) + + @expose('/', methods=['GET']) + @protect() + @safe + @permission_name('get') @rison(get_item_schema) @merge_response_func(merge_label_columns, API_LABEL_COLUMNS_RIS_KEY) @merge_response_func(merge_show_columns, API_SHOW_COLUMNS_RIS_KEY) @merge_response_func(merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY) @merge_response_func(merge_show_title, API_SHOW_TITLE_RIS_KEY) - def _get_item(self, pk, **kwargs): + def get(self, pk, **kwargs): + """Get item from Model + --- + get: + parameters: + - in: path + schema: + type: integer + parameters: + - in: query + name: q + schema: + $ref: '#/components/parameter/get_item_schema' + responses: + 200: + content: + application/json: + schema: + type: object + properties: + label_columns: + type: object + show_columns: + type: array + description_columns: + type: object + show_title: + type: string + id: + type: string + result: + type: object + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.get' + 400: + content: + application/json: + schema: + type: object + properties: + message: + type: string + 404: + content: + application/json: + schema: + type: object + properties: + message: + type: string + """ item = self.datamodel.get(pk, self._base_filters) if not item: return self.response_404() @@ -965,7 +1044,7 @@ def _get_item(self, pk, **kwargs): ] self.set_response_key_mappings( _response, - self._get_item, + self.get, _args, **{API_SELECT_COLUMNS_RIS_KEY: _pruned_select_cols} ) @@ -978,13 +1057,52 @@ def _get_item(self, pk, **kwargs): _response[API_RESULT_RES_KEY] = _show_model_schema.dump(item, many=False).data return self.response(200, **_response) + @expose('/', methods=['GET']) + @protect() + @safe + @permission_name('get') @rison(get_list_schema) @merge_response_func(merge_order_columns, API_ORDER_COLUMNS_RIS_KEY) @merge_response_func(merge_label_columns, API_LABEL_COLUMNS_RIS_KEY) @merge_response_func(merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY) @merge_response_func(merge_list_columns, API_LIST_COLUMNS_RIS_KEY) @merge_response_func(merge_list_title, API_LIST_TITLE_RIS_KEY) - def _get_list(self, **kwargs): + def get_list(self, **kwargs): + """Get list of items from Model + --- + get: + responses: + 200: + content: + application/json: + schema: + type: object + properties: + label_columns: + type: object + list_columns: + type: array + description_columns: + type: object + list_title: + type: string + ids: + type: array + order_columns: + type: array + result: + type: object + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.get_list' + 400: + content: + application/json: + schema: + type: object + properties: + message: + type: string + """ _response = dict() _args = kwargs.get('rison', {}) # handle select columns @@ -992,7 +1110,7 @@ def _get_list(self, **kwargs): _pruned_select_cols = [col for col in select_cols if col in self.list_columns] self.set_response_key_mappings( _response, - self._get_list, + self.get_list, _args, **{API_SELECT_COLUMNS_RIS_KEY: _pruned_select_cols} ) @@ -1023,6 +1141,172 @@ def _get_list(self, **kwargs): _response['count'] = count return self.response(200, **_response) + @expose('/', methods=['POST']) + @protect() + @safe + @permission_name('post') + def post(self): + """POST item to Model + --- + post: + responses: + 201: + content: + application/json: + schema: + type: object + properties: + id: + type: string + result: + type: object + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + 400: + content: + application/json: + schema: + type: object + properties: + message: + type: string + """ + if not request.is_json: + return self.response_400(message='Request is not JSON') + try: + item = self.add_model_schema.load(request.json) + except ValidationError as err: + return self.response_422(message=err.messages) + # This validates custom Schema with custom validations + if isinstance(item.data, dict): + return self.response_422(message=item.errors) + self.pre_add(item.data) + try: + self.datamodel.add(item.data, raise_exception=True) + self.post_add(item.data) + return self.response( + 201, + **{ + API_RESULT_RES_KEY: self.add_model_schema.dump( + item.data, many=False + ).data, + 'id': self.datamodel.get_pk_value(item.data) + } + ) + except IntegrityError as e: + return self.response_422(message=str(e.orig)) + + @expose('/', methods=['PUT']) + @protect() + @safe + @permission_name('put') + def put(self, pk): + """POST item to Model + --- + put: + responses: + 200: + content: + application/json: + schema: + type: object + properties: + result: + type: object + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' + 400: + content: + application/json: + schema: + type: object + properties: + message: + type: string + 404: + content: + application/json: + schema: + type: object + properties: + message: + type: string + """ + item = self.datamodel.get(pk, self._base_filters) + if not request.is_json: + return self.response(400, **{'message': 'Request is not JSON'}) + if not item: + return self.response_404() + try: + data = self._merge_update_item(item, request.json) + item = self.edit_model_schema.load(data, instance=item) + except ValidationError as err: + return self.response_422(message=err.messages) + # This validates custom Schema with custom validations + if isinstance(item.data, dict): + return self.response_422(message=item.errors) + self.pre_update(item.data) + try: + self.datamodel.edit(item.data, raise_exception=True) + self.post_update(item) + return self.response( + 200, + **{API_RESULT_RES_KEY: self.edit_model_schema.dump( + item.data, + many=False).data} + ) + except IntegrityError as e: + return self.response_422(message=str(e.orig)) + + @expose('/', methods=['DELETE']) + @protect() + @safe + @permission_name('delete') + def delete(self, pk): + """Delete item from Model + --- + delete: + parameters: + - in: path + schema: + type: integer + responses: + 200: + content: + application/json: + schema: + type: object + properties: + message: + type: string + 400: + content: + application/json: + schema: + type: object + properties: + message: + type: string + 404: + content: + application/json: + schema: + type: object + properties: + message: + type: string + """ + item = self.datamodel.get(pk, self._base_filters) + if not item: + return self.response_404() + self.pre_delete(item) + try: + self.datamodel.delete(item, raise_exception=True) + self.post_delete(item) + return self.response(200, message='OK') + except IntegrityError as e: + return self.response_422(message=str(e.orig)) + """ ------------------------------------------------ HELPER FUNCTIONS diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index b52f6ecfbc..ff51d6fef5 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -1,5 +1,6 @@ import logging - +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin from flask import Blueprint, url_for, current_app from .views import IndexView, UtilView from .filters import TemplateFilters @@ -130,7 +131,7 @@ def __init__(self, app=None, self.static_folder = static_folder self.static_url_path = static_url_path self.update_perms = update_perms - + self.apispec = None self.app = app if app is not None: @@ -158,6 +159,13 @@ def init_app(self, app, session): self.session = session self.sm = self.security_manager_class(self) self.bm = BabelManager(self) + self.apispec = APISpec( + title=self.app_name, + version="0.0.0", + openapi_version="3.0.2", + info=dict(description=self.app_name), + plugins=[MarshmallowPlugin()], + ) self._add_global_static() self._add_global_filters() app.before_request(self.sm.before_request) @@ -499,5 +507,3 @@ def _process_inner_views(self): for v in self.baseviews: if isinstance(v, inner_class) and v not in view.get_init_inner_views(): view.get_init_inner_views().append(v) - - diff --git a/flask_appbuilder/security/api.py b/flask_appbuilder/security/api.py index 44aefec170..865d804c11 100644 --- a/flask_appbuilder/security/api.py +++ b/flask_appbuilder/security/api.py @@ -22,13 +22,62 @@ class SecurityApi(BaseApi): resource_name = 'security' version = API_SECURITY_VERSION + def add_apispec_components(self): + super(SecurityApi, self).add_apispec_components() + jwt_scheme = {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} + self.appbuilder.apispec.components.security_scheme("jwt", jwt_scheme) + @expose('/login', methods=['POST']) @safe def login(self): - """ - Login endpoint for the API returns a JWT and possibly a refresh token - :return: Flask response with JSON payload containing an - access_token and refresh_token + """Login endpoint for the API returns a JWT and optionally a refresh token + --- + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + provider: + type: string + enum: + - db + - ldap + refresh: + type: boolean + responses: + 200: + content: + application/json: + schema: + type: object + properties: + access_token: + type: string + refresh_token: + type: string + 400: + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + content: + application/json: + schema: + type: object + properties: + message: + type: string """ if not request.is_json: return self.response_400(message="Request payload is not JSON") @@ -72,8 +121,25 @@ def refresh(self): """ Security endpoint for the refresh token, so we can obtain a new token without forcing the user to login again - :return: Flask Response with JSON payload containing - a new access_token + --- + post: + responses: + 200: + content: + application/json: + schema: + type: object + properties: + refresh_token: + type: string + 401: + content: + application/json: + schema: + type: object + properties: + message: + type: string """ resp = { API_SECURITY_REFRESH_TOKEN_KEY: create_access_token( diff --git a/setup.py b/setup.py index 9f9b1e64c1..8fd8e7e397 100644 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ def desc(): 'Flask-WTF>=0.14.2,<1', 'Flask-JWT-Extended>=3.18,<4', 'python-dateutil>=2.3,<3', - 'marshmallow>=2.18.0,<2.19', + 'marshmallow>=2.18.0,<2.20', 'marshmallow-enum>=1.4.1,<2' 'marshmallow-sqlalchemy>=0.16.1<1', 'prison==0.1.0', From 8fe9a556fa05c8860276a9a4dc871d30ba5557d0 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 5 Apr 2019 15:20:31 +0100 Subject: [PATCH 090/109] [ci] Fix, missing requirement --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index ef932a8cc4..f8872cf971 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,6 +24,7 @@ MarkupSafe==1.1.1 marshmallow==2.18.0 marshmallow-sqlalchemy==0.15.0 marshmallow-enum==1.4.1 +apispec==1.1.1 mockldap==0.3.0 mongoengine==0.7.10 nose==1.3.7 From aa3701112b61cb10e80de5cf6beeafcb8b1fc1bf Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 5 Apr 2019 15:30:58 +0100 Subject: [PATCH 091/109] [ci] Fix, missing requirement --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f8872cf971..ce471b7be6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ MarkupSafe==1.1.1 marshmallow==2.18.0 marshmallow-sqlalchemy==0.15.0 marshmallow-enum==1.4.1 -apispec==1.1.1 +apispec[yaml]==1.1.1 mockldap==0.3.0 mongoengine==0.7.10 nose==1.3.7 From 3853163a5bd3b697b1b217a5193435b198401a85 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Fri, 5 Apr 2019 23:57:48 +0100 Subject: [PATCH 092/109] [api] New, OpenAPI reference responses --- examples/base_api/app/api.py | 101 +++++++++++- flask_appbuilder/api/__init__.py | 275 +++++++++++++++++++++---------- flask_appbuilder/console.py | 12 ++ flask_appbuilder/security/api.py | 30 +--- 4 files changed, 307 insertions(+), 111 deletions(-) diff --git a/examples/base_api/app/api.py b/examples/base_api/app/api.py index 4fcbb1cc38..8259df9fbd 100644 --- a/examples/base_api/app/api.py +++ b/examples/base_api/app/api.py @@ -3,11 +3,11 @@ from flask_appbuilder.security.decorators import protect from . import appbuilder -schema = { +greeting_schema = { "type": "object", "properties": { "name": { - "type": "integer" + "type": "string" } } } @@ -16,13 +16,55 @@ class MyFirstApi(BaseApi): resource_name = 'myfirst' + apispec_parameter_schemas = { + "greeting_schema": greeting_schema + } @expose('/greeting') def greeting(self): + """Send a greeting + --- + get: + responses: + 200: + description: Greet the user + content: + application/json: + schema: + type: object + properties: + message: + type: string + """ return self.response(200, message="Hello") @expose('/greeting2', methods=['POST', 'GET']) def greeting2(self): + """Send a greeting + --- + get: + responses: + 200: + description: Greet the user + content: + application/json: + schema: + type: object + properties: + message: + type: string + post: + responses: + 201: + description: Greet the user + content: + application/json: + schema: + type: object + properties: + message: + type: string + """ if request.method == 'GET': return self.response(200, message="Hello (GET)") return self.response(201, message="Hello (POST)") @@ -38,27 +80,74 @@ def greeting3(self, **kwargs): return self.response_400(message="Please send your name") @expose('/greeting4') - @rison(schema) + @rison(greeting_schema) def greeting4(self, **kwargs): + """Get item from Model + --- + get: + parameters: + - $ref: '#/components/parameters/greeting_schema' + responses: + 200: + description: Greet the user + content: + application/json: + schema: + type: object + properties: + message: + type: string + """ return self.response( 200, message="Hello {}".format(kwargs['rison']['name']) ) @expose('/risonjson') - @rison + @rison() def rison_json(self, **kwargs): + """Say it's risonjson + --- + get: + responses: + 200: + description: Say it's private + content: + application/json: + schema: + type: object + """ return self.response(200, result=kwargs['rison']) @expose('/private') - @protect + @protect() def private(self): + """Say it's private + --- + get: + responses: + 200: + description: Say it's private + content: + application/json: + schema: + type: object + 401: + $ref: '#/components/responses/401' + """ return self.response(200, message="This is private") @expose('/error') - @protect + @protect() @safe def error(self): + """Error 500 + --- + get: + responses: + 500: + $ref: '#/components/responses/500' + """ raise Exception diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index e30aee023b..c526189c28 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -244,6 +244,87 @@ class CustomApi(BaseApi): """ _apispec_parameter_schemas = None + responses = { + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "422": { + "description": "Could not process entity", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Fatal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + """ + Override custom OpenApi responses + """ + def __init__(self): """ Initialization of base permissions @@ -292,7 +373,18 @@ def create_blueprint(self, appbuilder, return self.blueprint def add_apispec_components(self): - pass + for k, v in self.responses.items(): + self.appbuilder.apispec.components._responses[k] = v + for k, v in self._apispec_parameter_schemas.items(): + if k not in self.appbuilder.apispec.components._parameters: + _v = { + "in": "query", + "name": API_URI_RIS_KEY, + "schema": {"$ref": "#/components/schemas/{}".format(k)} + } + # Using private because parameter method does not behave correctly + self.appbuilder.apispec.components._schemas[k] = v + self.appbuilder.apispec.components._parameters[k] = _v def _register_urls(self): for attr_name in dir(self): @@ -317,6 +409,10 @@ def _register_urls(self): path=path, operations=operations ) + for operation in operations: + self.appbuilder.apispec._paths[path][operation]['tags'] = [ + self.__class__.__name__ + ] def path_helper(self, path=None, operations=None, **kwargs): """ @@ -765,11 +861,6 @@ def create_blueprint(self, appbuilder, *args, **kwargs): def add_apispec_components(self): super(ModelRestApi, self).add_apispec_components() - for k, v in self._apispec_parameter_schemas.items(): - try: - self.appbuilder.apispec.components.parameter(k, v) - except DuplicateComponentNameError: - continue self.appbuilder.apispec.components.schema( "{}.{}".format(self.__class__.__name__, "get_list"), schema=self.list_model_schema @@ -783,7 +874,7 @@ def add_apispec_components(self): schema=self.edit_model_schema ) self.appbuilder.apispec.components.schema( - "{}.{}".format(self.__class__.__name__, "get_item"), + "{}.{}".format(self.__class__.__name__, "get"), schema=self.show_model_schema ) @@ -963,9 +1054,37 @@ def merge_show_title(self, response, **kwargs): @merge_response_func(merge_add_title, API_ADD_TITLE_RIS_KEY) @merge_response_func(merge_edit_title, API_EDIT_TITLE_RIS_KEY) def info(self, **kwargs): - """ - Endpoint that renders a response for CRUD REST meta data - :param kwargs: Rison kwargs + """ Endpoint that renders a response for CRUD REST meta data + --- + get: + parameters: + - $ref: '#/components/parameters/get_info_schema' + responses: + 200: + description: Item from Model + content: + application/json: + schema: + type: object + properties: + add_columns: + type: object + edit_columns: + type: object + filters: + type: object + permissions: + type: array + items: + type: string + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' """ _response = dict() _args = kwargs.get('rison', {}) @@ -989,13 +1108,11 @@ def get(self, pk, **kwargs): - in: path schema: type: integer - parameters: - - in: query - name: q - schema: - $ref: '#/components/parameter/get_item_schema' + name: pk + - $ref: '#/components/parameters/get_item_schema' responses: 200: + description: Item from Model content: application/json: schema: @@ -1005,6 +1122,8 @@ def get(self, pk, **kwargs): type: object show_columns: type: array + items: + type: string description_columns: type: object show_title: @@ -1012,25 +1131,17 @@ def get(self, pk, **kwargs): id: type: string result: - type: object - schema: - $ref: '#/components/schemas/{{self.__class__.__name__}}.get' + $ref: '#/components/schemas/{{self.__class__.__name__}}.get' 400: - content: - application/json: - schema: - type: object - properties: - message: - type: string + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' 404: - content: - application/json: - schema: - type: object - properties: - message: - type: string + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' """ item = self.datamodel.get(pk, self._base_filters) if not item: @@ -1071,8 +1182,11 @@ def get_list(self, **kwargs): """Get list of items from Model --- get: + parameters: + - $ref: '#/components/parameters/get_item_schema' responses: 200: + description: Items from Model content: application/json: schema: @@ -1082,26 +1196,30 @@ def get_list(self, **kwargs): type: object list_columns: type: array + items: + type: string description_columns: type: object list_title: type: string ids: type: array + items: + type: string order_columns: type: array + items: + type: string result: - type: object - schema: - $ref: '#/components/schemas/{{self.__class__.__name__}}.get_list' + $ref: '#/components/schemas/{{self.__class__.__name__}}.get_list' 400: - content: - application/json: - schema: - type: object - properties: - message: - type: string + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' """ _response = dict() _args = kwargs.get('rison', {}) @@ -1151,6 +1269,7 @@ def post(self): post: responses: 201: + description: Item inserted content: application/json: schema: @@ -1159,17 +1278,15 @@ def post(self): id: type: string result: - type: object - schema: - $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' 400: - content: - application/json: - schema: - type: object - properties: - message: - type: string + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message='Request is not JSON') @@ -1204,33 +1321,31 @@ def put(self, pk): """POST item to Model --- put: + parameters: + - in: path + schema: + type: integer + name: pk responses: 200: + description: Item changed content: application/json: schema: type: object properties: result: - type: object - schema: - $ref: '#/components/schemas/{{self.__class__.__name__}}.put' + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' 400: - content: - application/json: - schema: - type: object - properties: - message: - type: string + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' 404: - content: - application/json: - schema: - type: object - properties: - message: - type: string + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' """ item = self.datamodel.get(pk, self._base_filters) if not request.is_json: @@ -1270,16 +1385,10 @@ def delete(self, pk): - in: path schema: type: integer + name: pk responses: 200: - content: - application/json: - schema: - type: object - properties: - message: - type: string - 400: + description: Item deleted content: application/json: schema: @@ -1288,13 +1397,11 @@ def delete(self, pk): message: type: string 404: - content: - application/json: - schema: - type: object - properties: - message: - type: string + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' """ item = self.datamodel.get(pk, self._base_filters) if not item: diff --git a/flask_appbuilder/console.py b/flask_appbuilder/console.py index 546e5feb2d..849256ee12 100644 --- a/flask_appbuilder/console.py +++ b/flask_appbuilder/console.py @@ -25,6 +25,7 @@ MONGOENGIE_REPO_URL = 'https://github.com/dpgaspar/Flask-AppBuilder-Skeleton-me/archive/master.zip' ADDON_REPO_URL = 'https://github.com/dpgaspar/Flask-AppBuilder-Skeleton-AddOn/archive/master.zip' + def import_application(app_package, appbuilder): sys.path.append(os.getcwd()) try: @@ -62,6 +63,17 @@ def cli_app(): pass +@cli_app.command("api-spec") +@click.option('--app', default='app', help='Your application init directory (package)') +@click.option('--appbuilder', default='appbuilder', help='your AppBuilder object') +def api_spec(app, appbuilder): + """ + Resets a user's password + """ + _appbuilder = import_application(app, appbuilder) + print(_appbuilder.apispec.to_yaml()) + + @cli_app.command("reset-password") @click.option('--app', default='app', help='Your application init directory (package)') @click.option('--appbuilder', default='appbuilder', help='your AppBuilder object') diff --git a/flask_appbuilder/security/api.py b/flask_appbuilder/security/api.py index 865d804c11..f78b44f4c0 100644 --- a/flask_appbuilder/security/api.py +++ b/flask_appbuilder/security/api.py @@ -53,6 +53,7 @@ def login(self): type: boolean responses: 200: + description: Authentication Successful content: application/json: schema: @@ -63,21 +64,11 @@ def login(self): refresh_token: type: string 400: - content: - application/json: - schema: - type: object - properties: - message: - type: string + $ref: '#/components/responses/400' 401: - content: - application/json: - schema: - type: object - properties: - message: - type: string + $ref: '#/components/responses/401' + 500: + $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request payload is not JSON") @@ -125,6 +116,7 @@ def refresh(self): post: responses: 200: + description: Refresh Successful content: application/json: schema: @@ -133,13 +125,9 @@ def refresh(self): refresh_token: type: string 401: - content: - application/json: - schema: - type: object - properties: - message: - type: string + $ref: '#/components/responses/401' + 500: + $ref: '#/components/responses/500' """ resp = { API_SECURITY_REFRESH_TOKEN_KEY: create_access_token( From 6ff2a77ded639c713c0387083f188ec79395da76 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sun, 7 Apr 2019 18:06:36 +0100 Subject: [PATCH 093/109] [api] Openapi endpoint for all versions, spec is only calc on demand --- examples/base_api/app/api.py | 4 +- flask_appbuilder/api/__init__.py | 85 ++++++++++++++++++++------------ flask_appbuilder/api/manager.py | 63 +++++++++++++++++++++++ flask_appbuilder/api/schemas.py | 6 ++- flask_appbuilder/base.py | 15 ++---- flask_appbuilder/console.py | 11 ----- flask_appbuilder/security/api.py | 6 +-- requirements.txt | 2 +- 8 files changed, 132 insertions(+), 60 deletions(-) create mode 100644 flask_appbuilder/api/manager.py diff --git a/examples/base_api/app/api.py b/examples/base_api/app/api.py index 8259df9fbd..3ff04e4425 100644 --- a/examples/base_api/app/api.py +++ b/examples/base_api/app/api.py @@ -70,7 +70,7 @@ def greeting2(self): return self.response(201, message="Hello (POST)") @expose('/greeting3') - @rison + @rison() def greeting3(self, **kwargs): if 'name' in kwargs['rison']: return self.response( @@ -132,7 +132,7 @@ def private(self): application/json: schema: type: object - 401: + 401: $ref: '#/components/responses/401' """ return self.response(200, message="This is private") diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index c526189c28..8828149a0f 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -4,7 +4,6 @@ import traceback import prison import jsonschema -from apispec.exceptions import DuplicateComponentNameError from sqlalchemy.exc import IntegrityError from marshmallow import ValidationError from marshmallow_sqlalchemy.fields import Related, RelatedList @@ -369,22 +368,44 @@ def create_blueprint(self, appbuilder, url_prefix=self.route_base) self._register_urls() - self.add_apispec_components() return self.blueprint - def add_apispec_components(self): + def add_api_spec(self, api_spec): + for attr_name in dir(self): + attr = getattr(self, attr_name) + if hasattr(attr, '_urls'): + for url, methods in attr._urls: + operations = dict() + path = self.path_helper(path=url, operations=operations) + self.operation_helper( + path=path, + operations=operations, + methods=methods, + func=attr + ) + api_spec.path( + path=path, + operations=operations + ) + for operation in operations: + api_spec._paths[path][operation]['tags'] = [ + self.__class__.__name__ + ] + self.add_apispec_components(api_spec) + + def add_apispec_components(self, api_spec): for k, v in self.responses.items(): - self.appbuilder.apispec.components._responses[k] = v + api_spec.components._responses[k] = v for k, v in self._apispec_parameter_schemas.items(): - if k not in self.appbuilder.apispec.components._parameters: + if k not in api_spec.components._parameters: _v = { "in": "query", "name": API_URI_RIS_KEY, "schema": {"$ref": "#/components/schemas/{}".format(k)} } # Using private because parameter method does not behave correctly - self.appbuilder.apispec.components._schemas[k] = v - self.appbuilder.apispec.components._parameters[k] = _v + api_spec.components._schemas[k] = v + api_spec.components._parameters[k] = _v def _register_urls(self): for attr_name in dir(self): @@ -397,22 +418,6 @@ def _register_urls(self): attr, methods=methods ) - operations = dict() - path = self.path_helper(path=url, operations=operations) - self.operation_helper( - path=path, - operations=operations, - methods=methods, - func=attr - ) - self.appbuilder.apispec.path( - path=path, - operations=operations - ) - for operation in operations: - self.appbuilder.apispec._paths[path][operation]['tags'] = [ - self.__class__.__name__ - ] def path_helper(self, path=None, operations=None, **kwargs): """ @@ -428,7 +433,7 @@ def path_helper(self, path=None, operations=None, **kwargs): """ RE_URL = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>') path = RE_URL.sub(r'{\1}', path) - return "{}{}".format(self.blueprint.url_prefix, path) + return "/{}{}".format(self.resource_name, path) def operation_helper( self, path=None, @@ -859,21 +864,21 @@ def create_blueprint(self, appbuilder, *args, **kwargs): **kwargs ) - def add_apispec_components(self): - super(ModelRestApi, self).add_apispec_components() - self.appbuilder.apispec.components.schema( + def add_apispec_components(self, api_spec): + super(ModelRestApi, self).add_apispec_components(api_spec) + api_spec.components.schema( "{}.{}".format(self.__class__.__name__, "get_list"), schema=self.list_model_schema ) - self.appbuilder.apispec.components.schema( + api_spec.components.schema( "{}.{}".format(self.__class__.__name__, "post"), schema=self.add_model_schema ) - self.appbuilder.apispec.components.schema( + api_spec.components.schema( "{}.{}".format(self.__class__.__name__, "put"), schema=self.edit_model_schema ) - self.appbuilder.apispec.components.schema( + api_spec.components.schema( "{}.{}".format(self.__class__.__name__, "get"), schema=self.show_model_schema ) @@ -1183,7 +1188,7 @@ def get_list(self, **kwargs): --- get: parameters: - - $ref: '#/components/parameters/get_item_schema' + - $ref: '#/components/parameters/get_list_schema' responses: 200: description: Items from Model @@ -1211,7 +1216,9 @@ def get_list(self, **kwargs): items: type: string result: - $ref: '#/components/schemas/{{self.__class__.__name__}}.get_list' + type: array + items: + $ref: '#/components/schemas/{{self.__class__.__name__}}.get_list' 400: $ref: '#/components/responses/400' 401: @@ -1267,6 +1274,13 @@ def post(self): """POST item to Model --- post: + requestBody: + description: Model schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' responses: 201: description: Item inserted @@ -1326,6 +1340,13 @@ def put(self, pk): schema: type: integer name: pk + requestBody: + description: Model schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' responses: 200: description: Item changed diff --git a/flask_appbuilder/api/manager.py b/flask_appbuilder/api/manager.py new file mode 100644 index 0000000000..2ce5f0663d --- /dev/null +++ b/flask_appbuilder/api/manager.py @@ -0,0 +1,63 @@ +from flask import current_app +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin +from flask_appbuilder.api import BaseApi +from flask_appbuilder.api import expose, safe, protect +from flask_appbuilder.basemanager import BaseManager + + +class OpenApi(BaseApi): + route_base = '/api' + allow_browser_login = True + + @expose('//_openapi') + @protect() + @safe + def get(self, version): + """ Endpoint that renders an OpenApi spec for all views that belong + to a certain version + --- + get: + parameters: + - in: path + schema: + type: string + name: version + responses: + 200: + description: Item from Model + content: + application/json: + schema: + type: object + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + version_found = False + api_spec = self._create_api_spec(version) + for base_api in current_app.appbuilder.baseviews: + if isinstance(base_api, BaseApi) and base_api.version == version: + base_api.add_api_spec(api_spec) + version_found = True + if version_found: + return self.response(200, **api_spec.to_dict()) + else: + return self.response_404() + + @staticmethod + def _create_api_spec(version): + return APISpec( + title=current_app.appbuilder.app_name, + version=version, + openapi_version="3.0.2", + info=dict(description=current_app.appbuilder.app_name), + plugins=[MarshmallowPlugin()], + servers=[{'url': "/api/{}".format(version)}] + ) + + +class OpenApiManager(BaseManager): + def register_views(self): + self.appbuilder.add_api(OpenApi) diff --git a/flask_appbuilder/api/schemas.py b/flask_appbuilder/api/schemas.py index ff5f13932f..9ddacc34bf 100644 --- a/flask_appbuilder/api/schemas.py +++ b/flask_appbuilder/api/schemas.py @@ -68,7 +68,11 @@ "type": "string" }, "value": { - "type": ["number", "string", "boolean", "null"] + "anyOf": [ + {"type": "number"}, + {"type": "string"}, + {"type": "boolean"} + ] } } } diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index ff51d6fef5..5dc6805ea6 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -1,9 +1,8 @@ import logging -from apispec import APISpec -from apispec.ext.marshmallow import MarshmallowPlugin from flask import Blueprint, url_for, current_app from .views import IndexView, UtilView from .filters import TemplateFilters +from .api.manager import OpenApiManager from .menu import Menu from .babel.manager import BabelManager from .version import VERSION_STRING @@ -79,6 +78,8 @@ class AppBuilder(object): sm = None # Babel Manager Class bm = None + # OpenAPI Manager Class + openapi_manager = None # dict with addon name has key and intantiated class has value addon_managers = None # temporary list that hold addon_managers config key @@ -131,7 +132,6 @@ def __init__(self, app=None, self.static_folder = static_folder self.static_url_path = static_url_path self.update_perms = update_perms - self.apispec = None self.app = app if app is not None: @@ -159,13 +159,7 @@ def init_app(self, app, session): self.session = session self.sm = self.security_manager_class(self) self.bm = BabelManager(self) - self.apispec = APISpec( - title=self.app_name, - version="0.0.0", - openapi_version="3.0.2", - info=dict(description=self.app_name), - plugins=[MarshmallowPlugin()], - ) + self.openapi_manager = OpenApiManager(self) self._add_global_static() self._add_global_filters() app.before_request(self.sm.before_request) @@ -269,6 +263,7 @@ def _add_admin_views(self): self.add_view_no_menu(UtilView()) self.bm.register_views() self.sm.register_views() + self.openapi_manager.register_views() def _add_addon_views(self): """ diff --git a/flask_appbuilder/console.py b/flask_appbuilder/console.py index 849256ee12..cd2c4d57bd 100644 --- a/flask_appbuilder/console.py +++ b/flask_appbuilder/console.py @@ -63,17 +63,6 @@ def cli_app(): pass -@cli_app.command("api-spec") -@click.option('--app', default='app', help='Your application init directory (package)') -@click.option('--appbuilder', default='appbuilder', help='your AppBuilder object') -def api_spec(app, appbuilder): - """ - Resets a user's password - """ - _appbuilder = import_application(app, appbuilder) - print(_appbuilder.apispec.to_yaml()) - - @cli_app.command("reset-password") @click.option('--app', default='app', help='Your application init directory (package)') @click.option('--appbuilder', default='appbuilder', help='your AppBuilder object') diff --git a/flask_appbuilder/security/api.py b/flask_appbuilder/security/api.py index f78b44f4c0..6e6a855568 100644 --- a/flask_appbuilder/security/api.py +++ b/flask_appbuilder/security/api.py @@ -22,10 +22,10 @@ class SecurityApi(BaseApi): resource_name = 'security' version = API_SECURITY_VERSION - def add_apispec_components(self): - super(SecurityApi, self).add_apispec_components() + def add_apispec_components(self, api_spec): + super(SecurityApi, self).add_apispec_components(api_spec) jwt_scheme = {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} - self.appbuilder.apispec.components.security_scheme("jwt", jwt_scheme) + api_spec.components.security_scheme("jwt", jwt_scheme) @expose('/login', methods=['POST']) @safe diff --git a/requirements.txt b/requirements.txt index ce471b7be6..f8872cf971 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ MarkupSafe==1.1.1 marshmallow==2.18.0 marshmallow-sqlalchemy==0.15.0 marshmallow-enum==1.4.1 -apispec[yaml]==1.1.1 +apispec==1.1.1 mockldap==0.3.0 mongoengine==0.7.10 nose==1.3.7 From 283400d66b40a81ba4d8e0c60aa8bc46451a49f8 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Sun, 7 Apr 2019 18:27:41 +0100 Subject: [PATCH 094/109] [api] [test] Fix, changed number of default views --- examples/crud_rest_api/app/__init__.py | 2 +- examples/crud_rest_api/app/{views.py => api.py} | 0 flask_appbuilder/tests/test_base.py | 2 +- flask_appbuilder/tests/test_mongoengine.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename examples/crud_rest_api/app/{views.py => api.py} (100%) diff --git a/examples/crud_rest_api/app/__init__.py b/examples/crud_rest_api/app/__init__.py index 8b9e8964b7..9123de75c0 100644 --- a/examples/crud_rest_api/app/__init__.py +++ b/examples/crud_rest_api/app/__init__.py @@ -25,4 +25,4 @@ def set_sqlite_pragma(dbapi_connection, connection_record): cursor.close() """ -from . import models, views +from . import models, api diff --git a/examples/crud_rest_api/app/views.py b/examples/crud_rest_api/app/api.py similarity index 100% rename from examples/crud_rest_api/app/views.py rename to examples/crud_rest_api/app/api.py diff --git a/flask_appbuilder/tests/test_base.py b/flask_appbuilder/tests/test_base.py index 0acc53d90b..9bdd3e8e26 100644 --- a/flask_appbuilder/tests/test_base.py +++ b/flask_appbuilder/tests/test_base.py @@ -287,7 +287,7 @@ def test_fab_views(self): """ Test views creation and registration """ - eq_(len(self.appbuilder.baseviews), 33) # current minimal views are 34 + eq_(len(self.appbuilder.baseviews), 34) # current minimal views are 34 def test_back(self): """ diff --git a/flask_appbuilder/tests/test_mongoengine.py b/flask_appbuilder/tests/test_mongoengine.py index e76633e016..574cc078a5 100644 --- a/flask_appbuilder/tests/test_mongoengine.py +++ b/flask_appbuilder/tests/test_mongoengine.py @@ -236,7 +236,7 @@ def test_fab_views(self): """ Test views creation and registration """ - eq_(len(self.appbuilder.baseviews), 25) # current minimal views are 26 + eq_(len(self.appbuilder.baseviews), 26) # current minimal views are 26 def test_index(self): """ From f1f91e6b763a044a24bb98dec947944226abb1f9 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 8 Apr 2019 09:49:10 +0100 Subject: [PATCH 095/109] [api] [docs] OpenAPI spec documentation --- docs/rest_api.rst | 227 ++++++++++++++++++++++++++++++----- examples/base_api/app/api.py | 6 +- 2 files changed, 201 insertions(+), 32 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 4162633e4d..011fbfd052 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -15,13 +15,12 @@ custom API endpoints:: from . import appbuilder - class MyFirstApi(BaseApi): - @expose('/greeting') + class ExampleApi(BaseApi): def greeting(self): return self.response(200, message="Hello") - appbuilder.add_api(MyFirstApi) + appbuilder.add_api(ExampleApi) On the previous example, we are exposing an HTTP GET endpoint, @@ -37,11 +36,11 @@ to be associated with a Flask blueprint. A ``BaseApi`` class defines a blueprint contains all exposed methods. By default the base route of the class blueprint is defined by: -/api/v1/ +``/api/v1/`` So we can make a request to our method using:: - $ curl http://localhost:8080/api/v1/myfirstapi/greeting + $ curl http://localhost:8080/api/v1/exampleapi/greeting To override the base route class blueprint, override the ``base_route`` property, so on our previous example:: @@ -50,7 +49,7 @@ so on our previous example:: from . import appbuilder - class MyFirstApi(BaseApi): + class ExampleApi(BaseApi): base_route = '/newapi/v2/nice' @@ -59,7 +58,7 @@ so on our previous example:: return self.response(200, message="Hello") - appbuilder.add_api(MyFirstApi) + appbuilder.add_api(ExampleApi) Now our endpoint will be:: @@ -72,20 +71,20 @@ using ``version`` and ``resource_name`` properties:: from . import appbuilder - class MyFirstApi(BaseApi): + class ExampleApi(BaseApi): - resource_name = 'nice' + resource_name = 'example' @expose('/greeting') def greeting(self): return self.response(200, message="Hello") - appbuilder.add_api(MyFirstApi) + appbuilder.add_api(ExampleApi) Now our endpoint will be:: - $ curl http://localhost:8080/api/v1/nice/greeting + $ curl http://localhost:8080/api/v1/example/greeting The other HTTP methods (PUT, POST, DELETE, ...) can be defined just like @@ -94,7 +93,7 @@ a Flask route signature:: from flask import request from flask_appbuilder.api import BaseApi, expose - class MyFirstApi(BaseApi): + class ExampleApi(BaseApi): .... @@ -107,15 +106,16 @@ a Flask route signature:: The previous example will expose a new `greeting2` endpoint on HTTP GET and POST so we can request it by:: - $ curl http://localhost:8080/api/v1/myfirstapi/greeting2 + $ curl http://localhost:8080/api/v1/example/greeting2 { "message": "Hello (GET)" } - $ curl -XPOST http://localhost:8080/api/v1/myfirstapi/greeting2 + $ curl -XPOST http://localhost:8080/api/v1/example/greeting2 { "message": "Hello (POST)" } + Let's make our method a bit more interesting, and send our name on the HTTP GET method. You can optionally use a ``@rison`` decorator that will parse the HTTP URI arguments from a *Rison* structure to a python data structure. @@ -127,7 +127,7 @@ so data can be translated back and forth without loss or guesswork:: from flask_appbuilder.api import BaseApi, expose, rison - class MyFirstApi(BaseApi): + class ExampleApi(BaseApi): ... @@ -143,7 +143,7 @@ so data can be translated back and forth without loss or guesswork:: And to test our method:: - $ curl 'http://localhost:8080/api/v1/myfirstapi/greeting3?q=(name:daniel)' + $ curl 'http://localhost:8080/api/v1/example/greeting3?q=(name:daniel)' { "message": "Hello daniel" } @@ -189,7 +189,7 @@ So to test our concept:: Then call it:: - $ curl 'http://localhost:8080/api/v1/myfirstapi/risonjson?q=(bool:!t,list:!(a,b,c),null:!n,number:777,string:'string')' + $ curl 'http://localhost:8080/api/v1/example/risonjson?q=(bool:!t,list:!(a,b,c),null:!n,number:777,string:'string')' { "result": { "bool": true, @@ -210,7 +210,7 @@ so you can always use *normal* URI arguments using Flask's ``request.args`` If we send an invalid *Rison* argument we get an error:: - $ curl -v 'http://localhost:8080/api/v1/myfirstapi/risonjson?q=(bool:!t' + $ curl -v 'http://localhost:8080/api/v1/example/risonjson?q=(bool:!t' ... < HTTP/1.0 400 BAD REQUEST < Content-Type: application/json; charset=utf-8 @@ -226,7 +226,7 @@ validate your Rison arguments, this way you can implement a very strict API easi "type": "object", "properties": { "name": { - "type": "integer" + "type": "string" } } } @@ -254,6 +254,148 @@ You can enable or disable stack trace response using the def error(self): raise Exception +OpenAPI spec +------------ + +We can define an OpenAPI specification by using YAML on the docs section of our +methods:: + + @expose('/greeting') + def greeting(self): + """Send a greeting + --- + get: + responses: + 200: + description: Greet the user + content: + application/json: + schema: + type: object + properties: + message: + type: string + """ + return self.response(200, message="Hello") + + +We are defining that, our endpoint will respond to HTTP GET with a JSON object that contains +a key ``message`` with values of type **string**. To access all our OpenAPI specifications +request it on ``/api/v1/_openapi``, this is a dynamic endpoint that will serve all specs +from different API versions. So if we register an API for version **v2** we access it's +spec on ``/api/v2/_openapi``. Please note that OpenAPI specs are subject to authentication. + +So our spec for a method that accepts two HTTP verbs:: + + @expose('/greeting2', methods=['POST', 'GET']) + def greeting2(self): + """Send a greeting + --- + get: + responses: + 200: + description: Greet the user + content: + application/json: + schema: + type: object + properties: + message: + type: string + post: + responses: + 201: + description: Greet the user + content: + application/json: + schema: + type: object + properties: + message: + type: string + """ + if request.method == 'GET': + return self.response(200, message="Hello (GET)") + return self.response(201, message="Hello (POST)") + + +On Swagger UI our example API looks like: + +.. image:: ./images/swagger001.png + :width: 70% + + +Notice the ``get`` and ``put`` structures, we should always detail all our +possible responses. The ``BaseApi`` class comes with some pre packaged HTTP +responses we can use for the sake of brevity:: + + @expose('/error') + @protect() + @safe + def error(self): + """Error 500 + --- + get: + responses: + 500: + $ref: '#/components/responses/500' + """ + raise Exception + +At complete list of packaged responses you can use:: + + responses: + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + +The automatic OpenAPI spec generation also supports **Rison** arguments and their +json schema spec. Since both are compatible we can reuse our Json schema spec on OpenAPI. +First we need to register our spec, using ``apispec_parameter_schemas`` dictionary:: + + + class ExampleApi(BaseApi): + + resource_name = 'example' + apispec_parameter_schemas = { + "greeting_schema": greeting_schema + } + + +FAB will register your schema on ``/components/parameters``, so you can now +easily reference them:: + + @expose('/greeting4') + @rison(greeting_schema) + def greeting4(self, **kwargs): + """Get item from Model + --- + get: + parameters: + - $ref: '#/components/parameters/greeting_schema' + responses: + 200: + description: Greet the user + content: + application/json: + schema: + type: object + properties: + message: + type: string + """ + return self.response( + 200, + message="Hello {}".format(kwargs['rison']['name']) + ) + Security -------- @@ -276,21 +418,34 @@ Next, let's see how to create a private method:: from . import appbuilder - class MyFirstApi(BaseApi): + class ExampleApi(BaseApi): ... @expose('/private') @protect() def rison_json(self): + """Say it's risonjson + --- + get: + responses: + 200: + description: Say it's private + content: + application/json: + schema: + type: object + 401: + $ref: '#/components/responses/401' + """ return self.response(200, message="This is private") - appbuilder.add_api(MyFirstApi) + appbuilder.add_api(ExampleApi) Accessing this method as expected will return an HTTP 401 not authorized code and message:: - $ curl -v 'http://localhost:8080/api/v1/myfirstapi/private' + $ curl -v 'http://localhost:8080/api/v1/example/private' ... < HTTP/1.0 401 UNAUTHORIZED < Content-Type: application/json @@ -340,7 +495,7 @@ Let's request our Token then:: Next we can use our token on protected endpoints:: - $ curl 'http://localhost:8080/api/v1/myfirstapi/private' -H "Authorization: Bearer $TOKEN" + $ curl 'http://localhost:8080/api/v1/example/private' -H "Authorization: Bearer $TOKEN" { "message": "This is private" } @@ -348,7 +503,7 @@ Next we can use our token on protected endpoints:: As always FAB created a new **can_private** permission on the DB and as associated it to the *Admin* Role. So the Admin role as a new permission on -a view named "can private on MyFirstApi" +a view named "can private on ExampleApi" Note that you can protect all your methods and make them public or not by adding them to the *Public* Role. @@ -357,7 +512,7 @@ list property. This can be specially useful on ``ModelRestApi`` (up next) where we can restrict our Api resources to be read only, or only allow POST methods:: - class MyFirstApi(BaseApi): + class ExampleApi(BaseApi): base_permissions = ['can_private'] @@ -374,12 +529,12 @@ user Model:: Optionally you can enable signed cookie sessions (from flask-login) on the API. You can do it class or method wide:: - class MyFirstApi(BaseApi): + class ExampleApi(BaseApi): allow_browser_login = True The previous example will enable cookie sessions on the all class:: - class MyFirstApi(BaseApi): + class ExampleApi(BaseApi): @expose('/private') @protect(allow_browser_login=True) @@ -395,6 +550,9 @@ Model REST API To automatically create a RESTfull CRUD Api from a database *Model*, use ``ModelRestApi`` class and define it almost like an MVC ``ModelView``. This class will expose the following REST endpoints +:note: + Follow this example on Flask-AppBuilder project ./examples/crud_rest_api/ + .. cssclass:: table-bordered table-hover +-----------------------------+-------------------------------------------------------+-----------------+--------+ @@ -414,8 +572,13 @@ define it almost like an MVC ``ModelView``. This class will expose the following +-----------------------------+-------------------------------------------------------+-----------------+--------+ For each ``ModelRestApi`` you will get 5 CRUD endpoints and an extra information method. +All created CRUD endpoints have their OpenAPI spec accessible on ``/api//_openapi``, +each class is tagged so the CRUD endpoints get nicely grouped when using Swagger UI. +Notice that ``ModelRestApi`` will generate a complete OpenAPI schema models for you data, +so you can get free documentation for you API's. Let's dive into a simple example using the quickhowto. -The quickhowto example as a Contact's Model and a Group Model, so each Contact belongs to a Group. +The quickhowto example as a Contact's Model and a Group Model, +so each Contact belongs to a Group. First let's define a CRUD REST Api for our Group model resource:: @@ -428,7 +591,7 @@ First let's define a CRUD REST Api for our Group model resource:: resource_name = 'group' datamodel = SQLAInterface(ContactGroup) - appbuilder.add_api(MyFirstApi) + appbuilder.add_api(ExampleApi) Behind the scenes FAB uses marshmallow-sqlalchemy to infer the Model to a Marshmallow Schema, that can be safely serialized and deserialized. Let's recall our Model definition for ``ContactGroup``:: @@ -440,6 +603,11 @@ that can be safely serialized and deserialized. Let's recall our Model definitio def __repr__(self): return self.name +Swagger UI API representation for groups: + +.. image:: ./images/swagger002.png + :width: 70% + All endpoints are protected so we need to request a JWT and use it on our REST resource, like shown before we need to make a PUT request to the login API endpoint:: @@ -467,6 +635,7 @@ First let's create a Group:: } } + We got back a response with the model id and result with the inserted data. Now let's query our newly created Group:: diff --git a/examples/base_api/app/api.py b/examples/base_api/app/api.py index 3ff04e4425..99bc07ca3b 100644 --- a/examples/base_api/app/api.py +++ b/examples/base_api/app/api.py @@ -13,9 +13,9 @@ } -class MyFirstApi(BaseApi): +class ExampleApi(BaseApi): - resource_name = 'myfirst' + resource_name = 'example' apispec_parameter_schemas = { "greeting_schema": greeting_schema } @@ -151,4 +151,4 @@ def error(self): raise Exception -appbuilder.add_api(MyFirstApi) +appbuilder.add_api(ExampleApi) From 293250da161e667c2cba85a8ab47416afd44152f Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 8 Apr 2019 10:00:07 +0100 Subject: [PATCH 096/109] [api] [docs] Fix, missing requirement --- rtd_requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/rtd_requirements.txt b/rtd_requirements.txt index 30c2de77d0..b0adfe8790 100644 --- a/rtd_requirements.txt +++ b/rtd_requirements.txt @@ -13,5 +13,6 @@ Flask-JWT-Extended>=3.18,<4 marshmallow==2.18.0 marshmallow-sqlalchemy==0.15.0 prison==0.1.0 +apispec==1.1.1 jsonschema==3.0.1 PyJWT>=1.7.1 From b39f6b7ed671e7b1b32197bad4c9dbded89c1557 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 8 Apr 2019 10:10:40 +0100 Subject: [PATCH 097/109] [api] [docs] Fix, missing images --- docs/images/swagger001.png | Bin 0 -> 41274 bytes docs/images/swagger002.png | Bin 0 -> 26185 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/images/swagger001.png create mode 100644 docs/images/swagger002.png diff --git a/docs/images/swagger001.png b/docs/images/swagger001.png new file mode 100644 index 0000000000000000000000000000000000000000..2882f56235229ac548c608a8a1b714f42c1fba77 GIT binary patch literal 41274 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 literal 0 HcmV?d00001 diff --git a/docs/images/swagger002.png b/docs/images/swagger002.png new file mode 100644 index 0000000000000000000000000000000000000000..17ba3c70e598415f9c108cb66e4fbef32791a342 GIT binary patch literal 26185 zcmce-byOT*(=Iv$CpaWH1V}<~cT3O^+}+(ZxCZy&I!p+b-~`vf-Q5Rw26sC{e(!tl z{l0a-b)|AoN*vZt?&e_7=

@u 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 aW~2p!6!UgNu7PY9jAx4K3MF!;AN~ve(y-D1 literal 0 HcmV?d00001 From db2d2a59b2be1b2c3c8124c25c4a5d88c3ce0dae Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 8 Apr 2019 10:25:40 +0100 Subject: [PATCH 098/109] [api] [docs] Fixes --- docs/rest_api.rst | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 011fbfd052..3d4f84779c 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -342,7 +342,7 @@ responses we can use for the sake of brevity:: """ raise Exception -At complete list of packaged responses you can use:: +A complete list of packaged responses you can use:: responses: 400: @@ -587,11 +587,12 @@ First let's define a CRUD REST Api for our Group model resource:: from . import appbuilder - class GroupModelRestApi(ModelRestApi): + class GroupModelApi(ModelRestApi): resource_name = 'group' datamodel = SQLAInterface(ContactGroup) - appbuilder.add_api(ExampleApi) + appbuilder.add_api(GroupModelApi) + Behind the scenes FAB uses marshmallow-sqlalchemy to infer the Model to a Marshmallow Schema, that can be safely serialized and deserialized. Let's recall our Model definition for ``ContactGroup``:: @@ -862,7 +863,7 @@ values from related fields, using our *quickhowto* example:: These related field values can be filtered server side using the ``add_query_rel_fields`` or ``edit_query_rel_fields``:: - class ContactModelRestApi(ModelRestApi): + class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) add_query_rel_fields = { @@ -871,7 +872,7 @@ or ``edit_query_rel_fields``:: You can also impose an order for these values server side using ``order_rel_fields``:: - class ContactModelRestApi(ModelRestApi): + class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) order_rel_fields = { @@ -903,7 +904,7 @@ Using Rison example:: We can also restrict server side the available fields for add and edit using ``add_columns`` and ``edit_columns``. Additionally you can use ``add_exclude_columns`` and ``edit_exclude_columns``:: - class ContactModelRestApi(ModelRestApi): + class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) add_columns = ['name'] @@ -1002,7 +1003,7 @@ Our *curl* command will look like:: We can restrict or add fields for the get item endpoint using the ``show_columns`` property. This takes precedence from the *Rison* arguments:: - class ContactModelRestApi(ModelRestApi): + class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) show_columns = ['name'] @@ -1030,7 +1031,7 @@ let's add a new function:: And then on the REST API:: - class ContactModelRestApi(ModelRestApi): + class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) show_columns = ['name', 'some_function'] @@ -1072,7 +1073,7 @@ To reduce or extend the default inferred columns from our *Model*. On server side we can use the ``list_columns`` property, this takes precedence over *Rison* arguments:: - class ContactModelRestApi(ModelRestApi): + class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) list_columns = ['name', 'address'] @@ -1083,7 +1084,7 @@ For ordering the results, the following will order contacts by name descending Z To set a default order server side use ``base_order`` tuple:: - class ContactModelRestApi(ModelRestApi): + class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) base_order = ('name', 'desc') @@ -1094,7 +1095,7 @@ Pagination, get the second page using page size of two (just an example):: To set the default page size server side:: - class ContactModelRestApi(ModelRestApi): + class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) page_size = 20 @@ -1130,7 +1131,7 @@ just fetching the **name** column. To impose base filters server side:: - class ContactModelRestApi(ModelRestApi): + class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) base_filters = [['name', FilterStartsWith, 'A']] @@ -1140,7 +1141,7 @@ operations Simple example using doted notation, FAB will infer the necessary join operation:: - class ContactModelRestApi(ModelRestApi): + class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) base_filters = [['contact_group.name', FilterStartsWith, 'F']] @@ -1154,7 +1155,7 @@ Updates and Partial Updates PUT methods allow for changing a **Model**. Allowed changes are controlled by ``edit_columns``:: - class ContactModelRestApi(ModelRestApi): + class ContactModelApi(ModelRestApi): resource_name = 'contact' datamodel = SQLAInterface(Contact) edit_columns = ['name'] From 0b10e18e6c255614d6c54abf401c01ddb2c8da7d Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 8 Apr 2019 11:16:34 +0100 Subject: [PATCH 099/109] [api] [tests] Test OpenAPI spec endpoint --- flask_appbuilder/tests/test_api.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index f714f22e1b..475a396291 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -1720,3 +1720,17 @@ def test_get_list_col_function(self): None ) ) + + def test_openapi(self): + """ + REST Api: Test OpenAPI spec + """ + client = self.app.test_client() + token = self.login(client, USERNAME, PASSWORD) + uri = 'api/v1/_openapi' + rv = self.auth_client_get( + client, + token, + uri + ) + eq_(rv.status_code, 200) From 321b44c4151446cb18852c0d1d7172ab162ae4e8 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 8 Apr 2019 12:53:14 +0100 Subject: [PATCH 100/109] [api] [ui] New, bundled optional Swagger UI --- docs/config.rst | 2 ++ docs/rest_api.rst | 2 ++ examples/base_api/config.py | 2 ++ examples/crud_rest_api/config.py | 2 ++ flask_appbuilder/api/manager.py | 18 +++++++++++++ .../templates/appbuilder/swagger/swagger.html | 27 +++++++++++++++++++ 6 files changed, 53 insertions(+) create mode 100644 flask_appbuilder/templates/appbuilder/swagger/swagger.html diff --git a/docs/config.rst b/docs/config.rst index 48300c4680..f3d5fdc2cc 100755 --- a/docs/config.rst +++ b/docs/config.rst @@ -199,6 +199,8 @@ Use config.py to configure the following parameters. By default it will use SQLL +-----------------------------------+--------------------------------------------+-----------+ | FAB_API_MAX_PAGE_SIZE | Sets a limit for FAB Model Api page size | No | +-----------------------------------+--------------------------------------------+-----------+ +| FAB_API_SWAGGER_UI | Enables a Swagger UI view (Boolean) | No | ++-----------------------------------+--------------------------------------------+-----------+ Using config.py diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 3d4f84779c..91440dba7b 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -319,6 +319,8 @@ So our spec for a method that accepts two HTTP verbs:: return self.response(201, message="Hello (POST)") +To access Swagger UI you must enable ``FAB_API_SWAGGER_UI = True`` on your config file +then goto ``http://localhost:8080/swaggerview/v1`` for OpenAPI **v1** definitions On Swagger UI our example API looks like: .. image:: ./images/swagger001.png diff --git a/examples/base_api/config.py b/examples/base_api/config.py index 1f3b147ee7..3a027b7e0c 100644 --- a/examples/base_api/config.py +++ b/examples/base_api/config.py @@ -20,6 +20,8 @@ #------------------------------ # GLOBALS FOR APP Builder #------------------------------ +FAB_API_SWAGGER_UI = True + BABEL_DEFAULT_LOCALE = 'en' BABEL_DEFAULT_FOLDER = 'translations' LANGUAGES = { diff --git a/examples/crud_rest_api/config.py b/examples/crud_rest_api/config.py index bba522f789..bcc56549fb 100644 --- a/examples/crud_rest_api/config.py +++ b/examples/crud_rest_api/config.py @@ -38,6 +38,8 @@ #------------------------------ # GLOBALS FOR GENERAL APP's #------------------------------ +FAB_API_SWAGGER_UI = True + UPLOAD_FOLDER = basedir + '/app/static/uploads/' IMG_UPLOAD_FOLDER = basedir + '/app/static/uploads/' IMG_UPLOAD_URL = '/static/uploads/' diff --git a/flask_appbuilder/api/manager.py b/flask_appbuilder/api/manager.py index 2ce5f0663d..39991fdda3 100644 --- a/flask_appbuilder/api/manager.py +++ b/flask_appbuilder/api/manager.py @@ -1,6 +1,7 @@ from flask import current_app from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin +from flask_appbuilder.baseviews import BaseView from flask_appbuilder.api import BaseApi from flask_appbuilder.api import expose, safe, protect from flask_appbuilder.basemanager import BaseManager @@ -58,6 +59,23 @@ def _create_api_spec(version): ) +class SwaggerView(BaseView): + + default_view = 'ui' + + title = "Example" + openapi_uri = '/api/{}/_openapi' + + @expose('/') + def ui(self, version): + return self.render_template( + 'appbuilder/swagger/swagger.html', + openapi_uri=self.openapi_uri.format(version) + ) + + class OpenApiManager(BaseManager): def register_views(self): self.appbuilder.add_api(OpenApi) + if self.appbuilder.get_app.config.get('FAB_API_SWAGGER_UI', False): + self.appbuilder.add_view_no_menu(SwaggerView) diff --git a/flask_appbuilder/templates/appbuilder/swagger/swagger.html b/flask_appbuilder/templates/appbuilder/swagger/swagger.html new file mode 100644 index 0000000000..79559170ba --- /dev/null +++ b/flask_appbuilder/templates/appbuilder/swagger/swagger.html @@ -0,0 +1,27 @@ +{% extends "appbuilder/base.html" %} + +{% block head_css %} +{{ super() }} + + +{% endblock %} + +{% block content %} +

+
+ + + +{% endblock %} From e62cc76a42bac955c7627e4e19b43c3bcc8d97e3 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 8 Apr 2019 12:59:09 +0100 Subject: [PATCH 101/109] [api] [docs] small change --- docs/rest_api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 91440dba7b..1180f42139 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -606,7 +606,7 @@ that can be safely serialized and deserialized. Let's recall our Model definitio def __repr__(self): return self.name -Swagger UI API representation for groups: +Swagger UI API representation for groups (http://localhost:8080/swaggerview/v1): .. image:: ./images/swagger002.png :width: 70% From 019eb6c710739112c5fd78cc23edf231dbd4d42c Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 8 Apr 2019 13:21:47 +0100 Subject: [PATCH 102/109] [ci] missing requirements --- flask_appbuilder/api/__init__.py | 5 ++--- requirements.txt | 1 - setup.py | 1 + 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index 8828149a0f..30d92d49b4 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -4,6 +4,8 @@ import traceback import prison import jsonschema +import yaml +from apispec import yaml_utils from sqlalchemy.exc import IntegrityError from marshmallow import ValidationError from marshmallow_sqlalchemy.fields import Related, RelatedList @@ -446,9 +448,6 @@ def operation_helper( :param dict operations: A `dict` mapping HTTP methods to operation object. See :param list methods: A list of methods registered for this path """ - import yaml - from apispec import yaml_utils - for method in methods: yaml_doc_string = yaml_utils.load_operations_from_docstring(func.__doc__) yaml_doc_string = yaml.safe_load(str(yaml_doc_string).replace( diff --git a/requirements.txt b/requirements.txt index f8872cf971..a53843c908 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,4 +42,3 @@ six==1.12.0 SQLAlchemy==1.3.1 Werkzeug==0.14.1 WTForms==2.2.1 - diff --git a/setup.py b/setup.py index 8fd8e7e397..bf4003c627 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ def desc(): install_requires=[ 'colorama>=0.3.9,<1', 'click>=6.7,<8', + 'apispec>=1.1.1<2', 'Flask>=0.12,<2', 'Flask-Babel>=0.11.1,<1', 'Flask-Login>=0.3,<0.5', From 56f2a5fe82449d9dfbb21ffb4fe83cd3e6c3dc78 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 8 Apr 2019 13:27:03 +0100 Subject: [PATCH 103/109] [ci] missing requirements --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bf4003c627..ffb95b86dc 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ def desc(): install_requires=[ 'colorama>=0.3.9,<1', 'click>=6.7,<8', - 'apispec>=1.1.1<2', + 'apispec[yaml]>=1.1.1<2', 'Flask>=0.12,<2', 'Flask-Babel>=0.11.1,<1', 'Flask-Login>=0.3,<0.5', From 1d96bba44d4526cd95ad43aba1f1e8488da487ab Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 8 Apr 2019 17:39:57 +0100 Subject: [PATCH 104/109] [api] Fix, better swagger manager --- flask_appbuilder/api/manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flask_appbuilder/api/manager.py b/flask_appbuilder/api/manager.py index 39991fdda3..f9bc97fbd4 100644 --- a/flask_appbuilder/api/manager.py +++ b/flask_appbuilder/api/manager.py @@ -5,6 +5,7 @@ from flask_appbuilder.api import BaseApi from flask_appbuilder.api import expose, safe, protect from flask_appbuilder.basemanager import BaseManager +from flask_appbuilder.security.decorators import has_access class OpenApi(BaseApi): @@ -62,12 +63,11 @@ def _create_api_spec(version): class SwaggerView(BaseView): default_view = 'ui' - - title = "Example" openapi_uri = '/api/{}/_openapi' @expose('/') - def ui(self, version): + @has_access + def show(self, version): return self.render_template( 'appbuilder/swagger/swagger.html', openapi_uri=self.openapi_uri.format(version) From 1775504e9631f7e711f38d0b008bf0a632b99ce8 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 9 Apr 2019 14:57:38 +0100 Subject: [PATCH 105/109] [api] New, before get and before get_list for custom data mutation --- docs/rest_api.rst | 12 +++++++ flask_appbuilder/api/__init__.py | 54 ++++++++++++++++---------------- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index 1180f42139..e82940686a 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -1324,6 +1324,18 @@ a simpler way of doing this using ``validators_columns`` property:: validators_columns = {'name': validate_name} +Pre and Post processing +----------------------- + +``ModelRestApi`` offers several methods that you can override to perform pre processing or post processing +on all HTTP methods. These methods are nice places to change data before submission or retrieval: + +.. automodule:: flask_appbuilder.api + + .. autoclass:: ModelRestApi + :members: pre_get, pre_get_list, pre_update, post_update, pre_add, post_add, pre_delete, post_delete + :noindex: + Enum Fields ----------- diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index 30d92d49b4..fe8152474e 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -1170,6 +1170,7 @@ def get(self, pk, **kwargs): _response['id'] = pk _response[API_RESULT_RES_KEY] = _show_model_schema.dump(item, many=False).data + self.pre_get(_response) return self.response(200, **_response) @expose('/', methods=['GET']) @@ -1263,6 +1264,7 @@ def get_list(self, **kwargs): _response[API_RESULT_RES_KEY] = _list_model_schema.dump(lst, many=True).data _response['ids'] = pks _response['count'] = count + self.pre_get_list(_response) return self.response(200, **_response) @expose('/', methods=['POST']) @@ -1574,21 +1576,13 @@ def _get_list_related_field(self, field, filter_rel_field, page=None, page_size= order_column, order_direction = '', '' if filter_rel_field: filters = filters.add_filter_list(filter_rel_field) - count, values = datamodel.query( - filters, - order_column, - order_direction, - page=page, - page_size=page_size, - ) - else: - count, values = datamodel.query( - filters, - order_column, - order_direction, - page=page, - page_size=page_size, - ) + count, values = datamodel.query( + filters, + order_column, + order_direction, + page=page, + page_size=page_size, + ) for value in values: ret.append( { @@ -1621,11 +1615,6 @@ def _merge_update_item(self, model_item, data): def pre_update(self, item): """ Override this, this method is called before the update takes place. - If an exception is raised by this method, - the message is shown to the user and the update operation is - aborted. Because of this behavior, it can be used as a way to - implement more complex logic around updates. For instance - allowing only the original creator of the object to update it. """ pass @@ -1638,8 +1627,6 @@ def post_update(self, item): def pre_add(self, item): """ Override this, will be called before add. - If an exception is raised by this method, - the message is shown to the user and the add operation is aborted. """ pass @@ -1652,11 +1639,6 @@ def post_add(self, item): def pre_delete(self, item): """ Override this, will be called before delete - If an exception is raised by this method, - the message is shown to the user and the delete operation is - aborted. Because of this behavior, it can be used as a way to - implement more complex logic around deletes. For instance - allowing only the original creator of the object to delete it. """ pass @@ -1665,3 +1647,21 @@ def post_delete(self, item): Override this, will be called after delete """ pass + + def pre_get(self, data): + """ + Override this, will be called before data is sent + to the requester on get item endpoint. + You can use it to mutate the response sent. + Note that any new field added will not be reflected on the OpenApi spec. + """ + pass + + def pre_get_list(self, data): + """ + Override this, will be called before data is sent + to the requester on get list endpoint. + You can use it to mutate the response sent + Note that any new field added will not be reflected on the OpenApi spec. + """ + pass From 8516053286ce02526e5ef462a90594aaa4c78c59 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 9 Apr 2019 16:04:00 +0100 Subject: [PATCH 106/109] [api] [style] Fix, flake8 compliance --- flask_appbuilder/api/__init__.py | 564 ++++++++++++++----------------- flask_appbuilder/api/convert.py | 2 +- flask_appbuilder/api/manager.py | 6 +- flask_appbuilder/api/schemas.py | 111 +++--- flask_appbuilder/security/api.py | 50 +-- 5 files changed, 332 insertions(+), 401 deletions(-) diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index fe8152474e..ccfdbdf19a 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -1,56 +1,58 @@ -import re -import logging import functools +import logging +import re import traceback -import prison -import jsonschema -import yaml + from apispec import yaml_utils -from sqlalchemy.exc import IntegrityError +from flask import Blueprint, current_app, jsonify, make_response, request +from flask_babel import lazy_gettext as _ +import jsonschema from marshmallow import ValidationError from marshmallow_sqlalchemy.fields import Related, RelatedList -from flask import Blueprint, make_response, jsonify, request, current_app +import prison +from sqlalchemy.exc import IntegrityError from werkzeug.exceptions import BadRequest -from flask_babel import lazy_gettext as _ +import yaml + from .convert import Model2SchemaConverter -from .schemas import get_list_schema, get_item_schema, get_info_schema -from ..security.decorators import permission_name, protect +from .schemas import get_info_schema, get_item_schema, get_list_schema from .._compat import as_unicode from ..const import ( - API_URI_RIS_KEY, - API_ORDER_COLUMNS_RES_KEY, - API_LABEL_COLUMNS_RES_KEY, - API_LIST_COLUMNS_RES_KEY, - API_DESCRIPTION_COLUMNS_RES_KEY, - API_SHOW_COLUMNS_RES_KEY, API_ADD_COLUMNS_RES_KEY, + API_ADD_COLUMNS_RIS_KEY, + API_ADD_TITLE_RES_KEY, + API_ADD_TITLE_RIS_KEY, + API_DESCRIPTION_COLUMNS_RES_KEY, + API_DESCRIPTION_COLUMNS_RIS_KEY, API_EDIT_COLUMNS_RES_KEY, + API_EDIT_COLUMNS_RIS_KEY, + API_EDIT_TITLE_RES_KEY, + API_EDIT_TITLE_RIS_KEY, API_FILTERS_RES_KEY, - API_PERMISSIONS_RES_KEY, - API_RESULT_RES_KEY, - API_ORDER_COLUMNS_RIS_KEY, + API_FILTERS_RIS_KEY, + API_LABEL_COLUMNS_RES_KEY, API_LABEL_COLUMNS_RIS_KEY, + API_LIST_COLUMNS_RES_KEY, API_LIST_COLUMNS_RIS_KEY, - API_DESCRIPTION_COLUMNS_RIS_KEY, - API_SHOW_COLUMNS_RIS_KEY, - API_ADD_COLUMNS_RIS_KEY, - API_EDIT_COLUMNS_RIS_KEY, - API_SELECT_COLUMNS_RIS_KEY, - API_FILTERS_RIS_KEY, - API_PERMISSIONS_RIS_KEY, + API_LIST_TITLE_RES_KEY, + API_LIST_TITLE_RIS_KEY, API_ORDER_COLUMN_RIS_KEY, + API_ORDER_COLUMNS_RES_KEY, + API_ORDER_COLUMNS_RIS_KEY, API_ORDER_DIRECTION_RIS_KEY, API_PAGE_INDEX_RIS_KEY, API_PAGE_SIZE_RIS_KEY, - API_LIST_TITLE_RES_KEY, - API_ADD_TITLE_RES_KEY, - API_EDIT_TITLE_RES_KEY, + API_PERMISSIONS_RES_KEY, + API_PERMISSIONS_RIS_KEY, + API_RESULT_RES_KEY, + API_SELECT_COLUMNS_RIS_KEY, + API_SHOW_COLUMNS_RES_KEY, + API_SHOW_COLUMNS_RIS_KEY, API_SHOW_TITLE_RES_KEY, - API_LIST_TITLE_RIS_KEY, - API_ADD_TITLE_RIS_KEY, - API_EDIT_TITLE_RIS_KEY, - API_SHOW_TITLE_RIS_KEY + API_SHOW_TITLE_RIS_KEY, + API_URI_RIS_KEY, ) +from ..security.decorators import permission_name, protect log = logging.getLogger(__name__) @@ -58,7 +60,7 @@ def get_error_msg(): """ (inspired on Superset code) - :return: + :return: (str) """ if current_app.config.get("FAB_API_SHOW_STACKTRACE"): return traceback.format_exc() @@ -119,26 +121,27 @@ def rison_json(self, **kwargs): def _rison(f): def wraps(self, *args, **kwargs): value = request.args.get(API_URI_RIS_KEY, None) - kwargs['rison'] = dict() + kwargs["rison"] = dict() if value: try: - kwargs['rison'] = \ - prison.loads(value) + kwargs["rison"] = prison.loads(value) except prison.decoder.ParserException: return self.response_400(message="Not a valid rison argument") if schema: try: - jsonschema.validate(instance=kwargs['rison'], schema=schema) + jsonschema.validate(instance=kwargs["rison"], schema=schema) except jsonschema.ValidationError as e: return self.response_400( message="Not a valid rison schema {}".format(e) ) return f(self, *args, **kwargs) + return functools.update_wrapper(wraps, f) + return _rison -def expose(url='/', methods=('GET',)): +def expose(url="/", methods=("GET",)): """ Use this decorator to expose API endpoints on your API classes. @@ -149,7 +152,7 @@ def expose(url='/', methods=('GET',)): """ def wrap(f): - if not hasattr(f, '_urls'): + if not hasattr(f, "_urls"): f._urls = [] f._urls.append((url, methods)) return f @@ -174,7 +177,7 @@ def merge_some_function(self, response, rison_args): """ def wrap(f): - if not hasattr(f, '_response_key_func_mappings'): + if not hasattr(f, "_response_key_func_mappings"): f._response_key_func_mappings = dict() f._response_key_func_mappings[key] = func return f @@ -196,26 +199,26 @@ class BaseApi(object): blueprint = None endpoint = None - version = 'v1' + version = "v1" """ Define the Api version for this resource/class """ route_base = None - """ - Define the route base where all methods will suffix from + """ + Define the route base where all methods will suffix from """ resource_name = None """ - Defines a custom resource name, overrides the inferred from Class name + Defines a custom resource name, overrides the inferred from Class name makes no sense to use it with route base """ base_permissions = None """ A list of allowed base permissions:: - + class ExampleApi(BaseApi): base_permissions = ['can_get'] - + """ allow_browser_login = False """ @@ -228,7 +231,7 @@ class ExampleApi(BaseApi): """ Set your custom Rison parameter schemas here so that they get registered on the OpenApi spec:: - + custom_parameter = { "type": "object" "properties": { @@ -237,7 +240,7 @@ class ExampleApi(BaseApi): } } } - + class CustomApi(BaseApi): apispec_parameter_schemas = { "custom_parameter": custom_parameter @@ -252,14 +255,10 @@ class CustomApi(BaseApi): "application/json": { "schema": { "type": "object", - "properties": { - "message": { - "type": "string" - } - } + "properties": {"message": {"type": "string"}}, } } - } + }, }, "401": { "description": "Unauthorized", @@ -267,14 +266,10 @@ class CustomApi(BaseApi): "application/json": { "schema": { "type": "object", - "properties": { - "message": { - "type": "string" - } - } + "properties": {"message": {"type": "string"}}, } } - } + }, }, "404": { "description": "Not found", @@ -282,14 +277,10 @@ class CustomApi(BaseApi): "application/json": { "schema": { "type": "object", - "properties": { - "message": { - "type": "string" - } - } + "properties": {"message": {"type": "string"}}, } } - } + }, }, "422": { "description": "Could not process entity", @@ -297,14 +288,10 @@ class CustomApi(BaseApi): "application/json": { "schema": { "type": "object", - "properties": { - "message": { - "type": "string" - } - } + "properties": {"message": {"type": "string"}}, } } - } + }, }, "500": { "description": "Fatal error", @@ -312,15 +299,11 @@ class CustomApi(BaseApi): "application/json": { "schema": { "type": "object", - "properties": { - "message": { - "type": "string" - } - } + "properties": {"message": {"type": "string"}}, } } - } - } + }, + }, } """ Override custom OpenApi responses @@ -340,22 +323,22 @@ def __init__(self): if self.base_permissions is None: self.base_permissions = set() for attr_name in dir(self): - if hasattr(getattr(self, attr_name), '_permission_name'): - _permission_name = \ - getattr(getattr(self, attr_name), '_permission_name') - self.base_permissions.add('can_' + _permission_name) + if hasattr(getattr(self, attr_name), "_permission_name"): + _permission_name = getattr( + getattr(self, attr_name), "_permission_name" + ) + self.base_permissions.add("can_" + _permission_name) self.base_permissions = list(self.base_permissions) if not self.extra_args: self.extra_args = dict() self._apis = dict() for attr_name in dir(self): - if hasattr(getattr(self, attr_name), '_extra'): - _extra = getattr(getattr(self, attr_name), '_extra') - for key in _extra: self._apis[key] = _extra[key] + if hasattr(getattr(self, attr_name), "_extra"): + _extra = getattr(getattr(self, attr_name), "_extra") + for key in _extra: + self._apis[key] = _extra[key] - def create_blueprint(self, appbuilder, - endpoint=None, - static_folder=None): + def create_blueprint(self, appbuilder, endpoint=None, static_folder=None): # Store appbuilder instance self.appbuilder = appbuilder # If endpoint name is not provided, get it from the class name @@ -363,11 +346,10 @@ def create_blueprint(self, appbuilder, self.resource_name = self.resource_name or self.__class__.__name__ if self.route_base is None: - self.route_base = \ - "/api/{}/{}".format(self.version, - self.resource_name.lower()) - self.blueprint = Blueprint(self.endpoint, __name__, - url_prefix=self.route_base) + self.route_base = "/api/{}/{}".format( + self.version, self.resource_name.lower() + ) + self.blueprint = Blueprint(self.endpoint, __name__, url_prefix=self.route_base) self._register_urls() return self.blueprint @@ -375,22 +357,16 @@ def create_blueprint(self, appbuilder, def add_api_spec(self, api_spec): for attr_name in dir(self): attr = getattr(self, attr_name) - if hasattr(attr, '_urls'): + if hasattr(attr, "_urls"): for url, methods in attr._urls: operations = dict() path = self.path_helper(path=url, operations=operations) self.operation_helper( - path=path, - operations=operations, - methods=methods, - func=attr - ) - api_spec.path( - path=path, - operations=operations + path=path, operations=operations, methods=methods, func=attr ) + api_spec.path(path=path, operations=operations) for operation in operations: - api_spec._paths[path][operation]['tags'] = [ + api_spec._paths[path][operation]["tags"] = [ self.__class__.__name__ ] self.add_apispec_components(api_spec) @@ -403,7 +379,7 @@ def add_apispec_components(self, api_spec): _v = { "in": "query", "name": API_URI_RIS_KEY, - "schema": {"$ref": "#/components/schemas/{}".format(k)} + "schema": {"$ref": "#/components/schemas/{}".format(k)}, } # Using private because parameter method does not behave correctly api_spec.components._schemas[k] = v @@ -412,14 +388,9 @@ def add_apispec_components(self, api_spec): def _register_urls(self): for attr_name in dir(self): attr = getattr(self, attr_name) - if hasattr(attr, '_urls'): + if hasattr(attr, "_urls"): for url, methods in attr._urls: - self.blueprint.add_url_rule( - url, - attr_name, - attr, - methods=methods - ) + self.blueprint.add_url_rule(url, attr_name, attr, methods=methods) def path_helper(self, path=None, operations=None, **kwargs): """ @@ -433,16 +404,13 @@ def path_helper(self, path=None, operations=None, **kwargs): :return: Return value should be a string or None. If a string is returned, it is set as the path. """ - RE_URL = re.compile(r'<(?:[^:<>]+:)?([^<>]+)>') - path = RE_URL.sub(r'{\1}', path) + RE_URL = re.compile(r"<(?:[^:<>]+:)?([^<>]+)>") + path = RE_URL.sub(r"{\1}", path) return "/{}{}".format(self.resource_name, path) def operation_helper( - self, path=None, - operations=None, - methods=None, - func=None, - **kwargs): + self, path=None, operations=None, methods=None, func=None, **kwargs + ): """May mutate operations. :param str path: Path to the resource :param dict operations: A `dict` mapping HTTP methods to operation object. See @@ -450,9 +418,11 @@ def operation_helper( """ for method in methods: yaml_doc_string = yaml_utils.load_operations_from_docstring(func.__doc__) - yaml_doc_string = yaml.safe_load(str(yaml_doc_string).replace( - "{{self.__class__.__name__}}", - self.__class__.__name__)) + yaml_doc_string = yaml.safe_load( + str(yaml_doc_string).replace( + "{{self.__class__.__name__}}", self.__class__.__name__ + ) + ) if yaml_doc_string: operations[method.lower()] = yaml_doc_string.get(method.lower(), {}) else: @@ -468,7 +438,7 @@ def _prettify_name(name): :param name: Name to prettify. """ - return re.sub(r'(?<=.)([A-Z])', r' \1', name) + return re.sub(r"(?<=.)([A-Z])", r" \1", name) @staticmethod def _prettify_column(name): @@ -480,7 +450,7 @@ def _prettify_column(name): :param name: Name to prettify. """ - return re.sub('[._]', ' ', name).title() + return re.sub("[._]", " ", name).title() def get_uninit_inner_views(self): """ @@ -496,9 +466,9 @@ def get_init_inner_views(self, views): pass def set_response_key_mappings(self, response, func, rison_args, **kwargs): - if not hasattr(func, '_response_key_func_mappings'): + if not hasattr(func, "_response_key_func_mappings"): return - _keys = rison_args.get('keys', None) + _keys = rison_args.get("keys", None) if not _keys: for k, v in func._response_key_func_mappings.items(): v(self, response, **kwargs) @@ -508,10 +478,9 @@ def set_response_key_mappings(self, response, func, rison_args, **kwargs): v(self, response, **kwargs) def merge_current_user_permissions(self, response, **kwargs): - response[API_PERMISSIONS_RES_KEY] = \ - self.appbuilder.sm.get_user_permissions_on_view( - self.__class__.__name__ - ) + response[ + API_PERMISSIONS_RES_KEY + ] = self.appbuilder.sm.get_user_permissions_on_view(self.__class__.__name__) @staticmethod def response(code, **kwargs): @@ -524,7 +493,7 @@ def response(code, **kwargs): """ _ret_json = jsonify(kwargs) resp = make_response(_ret_json, code) - resp.headers['Content-Type'] = "application/json; charset=utf-8" + resp.headers["Content-Type"] = "application/json; charset=utf-8" return resp def response_400(self, message=None): @@ -586,7 +555,7 @@ class MyModelApi(BaseModelApi): """ search_columns = None """ - List with allowed search columns, if not provided all possible search + List with allowed search columns, if not provided all possible search columns will be used. If you want to limit the search (*filter*) columns possibilities, define it with a list of column names from your model:: @@ -597,7 +566,7 @@ class MyView(ModelRestApi): """ search_exclude_columns = None """ - List with columns to exclude from search. Search includes all possible + List with columns to exclude from search. Search includes all possible columns by default """ label_columns = None @@ -641,16 +610,16 @@ class MyView(ModelRestApi): _base_filters = None """ Internal base Filter from class Filters will always filter view """ _filters = None - """ - Filters object will calculate all possible filter types - based on search_columns + """ + Filters object will calculate all possible filter types + based on search_columns """ def __init__(self, **kwargs): """ Constructor """ - datamodel = kwargs.get('datamodel', None) + datamodel = kwargs.get("datamodel", None) if datamodel: self.datamodel = datamodel self._init_properties() @@ -673,7 +642,7 @@ def _label_columns_json(self, cols=None): cols = cols or [] d = {k: v for (k, v) in self.label_columns.items() if k in cols} for key, value in d.items(): - ret[key] = as_unicode(_(value).encode('UTF-8')) + ret[key] = as_unicode(_(value).encode("UTF-8")) return ret def _init_properties(self): @@ -682,13 +651,15 @@ def _init_properties(self): self.search_exclude_columns = self.search_exclude_columns or [] self.search_columns = self.search_columns or [] - self._base_filters = \ - self.datamodel.get_filters().add_filter_list(self.base_filters) + self._base_filters = self.datamodel.get_filters().add_filter_list( + self.base_filters + ) list_cols = self.datamodel.get_columns_list() search_columns = self.datamodel.get_search_columns_list() if not self.search_columns: - self.search_columns = \ - [x for x in search_columns if x not in self.search_exclude_columns] + self.search_columns = [ + x for x in search_columns if x not in self.search_exclude_columns + ] self._gen_labels_columns(list_cols) self._filters = self.datamodel.get_filters(self.search_columns) @@ -699,23 +670,23 @@ def _init_titles(self): class ModelRestApi(BaseModelApi): list_title = "" - """ - List Title, if not configured the default is - 'List ' with pretty model name + """ + List Title, if not configured the default is + 'List ' with pretty model name """ show_title = "" """ - Show Title , if not configured the default is - 'Show ' with pretty model name + Show Title , if not configured the default is + 'Show ' with pretty model name """ add_title = "" - """ - Add Title , if not configured the default is + """ + Add Title , if not configured the default is 'Add ' with pretty model name """ edit_title = "" - """ - Edit Title , if not configured the default is + """ + Edit Title , if not configured the default is 'Edit ' with pretty model name """ @@ -731,7 +702,7 @@ class ModelRestApi(BaseModelApi): """ add_columns = None """ - A list of columns (or model's methods) to be allowed to post + A list of columns (or model's methods) to be allowed to post """ edit_columns = None """ @@ -739,12 +710,12 @@ class ModelRestApi(BaseModelApi): """ list_exclude_columns = None """ - A list of columns to exclude from the get list endpoint. + A list of columns to exclude from the get list endpoint. By default all columns are included. """ show_exclude_columns = None """ - A list of columns to exclude from the get item endpoint. + A list of columns to exclude from the get item endpoint. By default all columns are included. """ add_exclude_columns = None @@ -784,11 +755,11 @@ class MyView(ModelView): same format as base_filter {'relation col name':[['Related model col',FilterClass,'Filter Value'],...],...} Add a custom filter to form related fields:: - + class ContactModelView(ModelRestApi): datamodel = SQLAModel(Contact) add_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} - + """ edit_query_rel_fields = None """ @@ -798,17 +769,17 @@ class ContactModelView(ModelRestApi): same format as base_filter {'relation col name':[['Related model col',FilterClass,'Filter Value'],...],...} Add a custom filter to form related fields:: - + class ContactModelView(ModelRestApi): datamodel = SQLAModel(Contact, db.session) edit_query_rel_fields = {'group':[['name',FilterStartsWith,'W']]} - + """ order_rel_fields = None """ Impose order on related fields. assign a dictionary where the keys are the related column names:: - + class ContactModelView(ModelRestApi): datamodel = SQLAModel(Contact) order_rel_fields = { @@ -818,92 +789,83 @@ class ContactModelView(ModelRestApi): """ list_model_schema = None """ - Override to provide your own marshmallow Schema + Override to provide your own marshmallow Schema for JSON to SQLA dumps """ add_model_schema = None """ - Override to provide your own marshmallow Schema + Override to provide your own marshmallow Schema for JSON to SQLA dumps """ edit_model_schema = None """ - Override to provide your own marshmallow Schema + Override to provide your own marshmallow Schema for JSON to SQLA dumps """ show_model_schema = None """ - Override to provide your own marshmallow Schema + Override to provide your own marshmallow Schema for JSON to SQLA dumps """ model2schemaconverter = Model2SchemaConverter """ - Override to use your own Model2SchemaConverter + Override to use your own Model2SchemaConverter (inherit from BaseModel2SchemaConverter) """ _apispec_parameter_schemas = { "get_info_schema": get_info_schema, "get_item_schema": get_item_schema, - "get_list_schema": get_list_schema + "get_list_schema": get_list_schema, } def __init__(self): super(ModelRestApi, self).__init__() self.validators_columns = self.validators_columns or {} self.model2schemaconverter = self.model2schemaconverter( - self.datamodel, - self.validators_columns + self.datamodel, self.validators_columns ) def create_blueprint(self, appbuilder, *args, **kwargs): self._init_model_schemas() - return super(ModelRestApi, self).create_blueprint( - appbuilder, - *args, - **kwargs - ) + return super(ModelRestApi, self).create_blueprint(appbuilder, *args, **kwargs) def add_apispec_components(self, api_spec): super(ModelRestApi, self).add_apispec_components(api_spec) api_spec.components.schema( "{}.{}".format(self.__class__.__name__, "get_list"), - schema=self.list_model_schema + schema=self.list_model_schema, ) api_spec.components.schema( "{}.{}".format(self.__class__.__name__, "post"), - schema=self.add_model_schema + schema=self.add_model_schema, ) api_spec.components.schema( "{}.{}".format(self.__class__.__name__, "put"), - schema=self.edit_model_schema + schema=self.edit_model_schema, ) api_spec.components.schema( "{}.{}".format(self.__class__.__name__, "get"), - schema=self.show_model_schema + schema=self.show_model_schema, ) def _init_model_schemas(self): # Create Marshmalow schemas if one is not specified if self.list_model_schema is None: - self.list_model_schema = \ - self.model2schemaconverter.convert(self.list_columns) + self.list_model_schema = self.model2schemaconverter.convert( + self.list_columns + ) if self.add_model_schema is None: - self.add_model_schema = \ - self.model2schemaconverter.convert( - self.add_columns, - nested=False, - enum_dump_by_name=True - ) + self.add_model_schema = self.model2schemaconverter.convert( + self.add_columns, nested=False, enum_dump_by_name=True + ) if self.edit_model_schema is None: - self.edit_model_schema = \ - self.model2schemaconverter.convert( - self.edit_columns, - nested=False, - enum_dump_by_name=True - ) + self.edit_model_schema = self.model2schemaconverter.convert( + self.edit_columns, nested=False, enum_dump_by_name=True + ) if self.show_model_schema is None: - self.show_model_schema = \ - self.model2schemaconverter.convert(self.show_columns) + self.show_model_schema = self.model2schemaconverter.convert( + self.show_columns + ) def _init_titles(self): """ @@ -912,13 +874,13 @@ def _init_titles(self): super(ModelRestApi, self)._init_titles() class_name = self.datamodel.model_name if not self.list_title: - self.list_title = 'List ' + self._prettify_name(class_name) + self.list_title = "List " + self._prettify_name(class_name) if not self.add_title: - self.add_title = 'Add ' + self._prettify_name(class_name) + self.add_title = "Add " + self._prettify_name(class_name) if not self.edit_title: - self.edit_title = 'Edit ' + self._prettify_name(class_name) + self.edit_title = "Edit " + self._prettify_name(class_name) if not self.show_title: - self.show_title = 'Show ' + self._prettify_name(class_name) + self.show_title = "Show " + self._prettify_name(class_name) self.title = self.list_title def _init_properties(self): @@ -939,48 +901,50 @@ def _init_properties(self): list(self.list_model_schema._declared_fields.keys()) else: self.list_columns = self.list_columns or [ - x for x in self.datamodel.get_user_columns_list() + x + for x in self.datamodel.get_user_columns_list() if x not in self.list_exclude_columns ] self._gen_labels_columns(self.list_columns) - self.order_columns = self.order_columns or \ - self.datamodel.get_order_columns_list( - list_columns=self.list_columns - ) + self.order_columns = ( + self.order_columns or + self.datamodel.get_order_columns_list(list_columns=self.list_columns) + ) # Process excluded columns if not self.show_columns: - self.show_columns = \ - [x for x in list_cols if x not in self.show_exclude_columns] + self.show_columns = [ + x for x in list_cols if x not in self.show_exclude_columns + ] if not self.add_columns: - self.add_columns = \ - [x for x in list_cols if x not in self.add_exclude_columns] + self.add_columns = [ + x for x in list_cols if x not in self.add_exclude_columns + ] if not self.edit_columns: - self.edit_columns = \ - [x for x in list_cols if x not in self.edit_exclude_columns] + self.edit_columns = [ + x for x in list_cols if x not in self.edit_exclude_columns + ] self._filters = self.datamodel.get_filters(self.search_columns) self.edit_query_rel_fields = self.edit_query_rel_fields or dict() self.add_query_rel_fields = self.add_query_rel_fields or dict() def merge_add_field_info(self, response, **kwargs): - _kwargs = kwargs.get('add_columns', {}) - response[API_ADD_COLUMNS_RES_KEY] = \ - self._get_fields_info( - self.add_columns, - self.add_model_schema, - self.add_query_rel_fields, - **_kwargs - ) + _kwargs = kwargs.get("add_columns", {}) + response[API_ADD_COLUMNS_RES_KEY] = self._get_fields_info( + self.add_columns, + self.add_model_schema, + self.add_query_rel_fields, + **_kwargs + ) def merge_edit_field_info(self, response, **kwargs): - _kwargs = kwargs.get('edit_columns', {}) - response[API_EDIT_COLUMNS_RES_KEY] = \ - self._get_fields_info( - self.edit_columns, - self.edit_model_schema, - self.edit_query_rel_fields, - **_kwargs - ) + _kwargs = kwargs.get("edit_columns", {}) + response[API_EDIT_COLUMNS_RES_KEY] = self._get_fields_info( + self.edit_columns, + self.edit_model_schema, + self.edit_query_rel_fields, + **_kwargs + ) def merge_search_filters(self, response, **kwargs): # Get possible search fields and all possible operations @@ -988,8 +952,8 @@ def merge_search_filters(self, response, **kwargs): dict_filters = self._filters.get_search_filters() for col in self.search_columns: search_filters[col] = [ - {'name': as_unicode(flt.name), - 'operator': flt.arg_name} for flt in dict_filters[col] + {"name": as_unicode(flt.name), "operator": flt.arg_name} + for flt in dict_filters[col] ] response[API_FILTERS_RES_KEY] = search_filters @@ -1017,11 +981,13 @@ def merge_show_columns(self, response, **kwargs): def merge_description_columns(self, response, **kwargs): _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) if _pruned_select_cols: - response[API_DESCRIPTION_COLUMNS_RES_KEY] = \ - self._description_columns_json(_pruned_select_cols) + response[API_DESCRIPTION_COLUMNS_RES_KEY] = self._description_columns_json( + _pruned_select_cols + ) else: - response[API_DESCRIPTION_COLUMNS_RES_KEY] = \ - self._description_columns_json(self.show_columns) + response[API_DESCRIPTION_COLUMNS_RES_KEY] = self._description_columns_json( + self.show_columns + ) def merge_list_columns(self, response, **kwargs): _pruned_select_cols = kwargs.get(API_SELECT_COLUMNS_RIS_KEY, []) @@ -1035,7 +1001,8 @@ def merge_order_columns(self, response, **kwargs): if _pruned_select_cols: response[API_ORDER_COLUMNS_RES_KEY] = [ order_col - for order_col in self.order_columns if order_col in _pruned_select_cols + for order_col in self.order_columns + if order_col in _pruned_select_cols ] else: response[API_ORDER_COLUMNS_RES_KEY] = self.order_columns @@ -1046,12 +1013,14 @@ def merge_list_title(self, response, **kwargs): def merge_show_title(self, response, **kwargs): response[API_SHOW_TITLE_RES_KEY] = self.show_title - @expose('/_info', methods=['GET']) + @expose("/_info", methods=["GET"]) @protect() @safe @rison(get_info_schema) - @permission_name('info') - @merge_response_func(BaseApi.merge_current_user_permissions, API_PERMISSIONS_RIS_KEY) + @permission_name("info") + @merge_response_func( + BaseApi.merge_current_user_permissions, API_PERMISSIONS_RIS_KEY + ) @merge_response_func(merge_add_field_info, API_ADD_COLUMNS_RIS_KEY) @merge_response_func(merge_edit_field_info, API_EDIT_COLUMNS_RIS_KEY) @merge_response_func(merge_search_filters, API_FILTERS_RIS_KEY) @@ -1091,14 +1060,14 @@ def info(self, **kwargs): $ref: '#/components/responses/500' """ _response = dict() - _args = kwargs.get('rison', {}) + _args = kwargs.get("rison", {}) self.set_response_key_mappings(_response, self.info, _args, **_args) return self.response(200, **_response) - @expose('/', methods=['GET']) + @expose("/", methods=["GET"]) @protect() @safe - @permission_name('get') + @permission_name("get") @rison(get_item_schema) @merge_response_func(merge_label_columns, API_LABEL_COLUMNS_RIS_KEY) @merge_response_func(merge_show_columns, API_SHOW_COLUMNS_RIS_KEY) @@ -1152,11 +1121,9 @@ def get(self, pk, **kwargs): return self.response_404() _response = dict() - _args = kwargs.get('rison', {}) + _args = kwargs.get("rison", {}) select_cols = _args.get(API_SELECT_COLUMNS_RIS_KEY, []) - _pruned_select_cols = [ - col for col in select_cols if col in self.show_columns - ] + _pruned_select_cols = [col for col in select_cols if col in self.show_columns] self.set_response_key_mappings( _response, self.get, @@ -1168,15 +1135,15 @@ def get(self, pk, **kwargs): else: _show_model_schema = self.show_model_schema - _response['id'] = pk + _response["id"] = pk _response[API_RESULT_RES_KEY] = _show_model_schema.dump(item, many=False).data self.pre_get(_response) return self.response(200, **_response) - @expose('/', methods=['GET']) + @expose("/", methods=["GET"]) @protect() @safe - @permission_name('get') + @permission_name("get") @rison(get_list_schema) @merge_response_func(merge_order_columns, API_ORDER_COLUMNS_RIS_KEY) @merge_response_func(merge_label_columns, API_LABEL_COLUMNS_RIS_KEY) @@ -1218,7 +1185,7 @@ def get_list(self, **kwargs): result: type: array items: - $ref: '#/components/schemas/{{self.__class__.__name__}}.get_list' + $ref: '#/components/schemas/{{self.__class__.__name__}}.get_list' # noqa 400: $ref: '#/components/responses/400' 401: @@ -1229,7 +1196,7 @@ def get_list(self, **kwargs): $ref: '#/components/responses/500' """ _response = dict() - _args = kwargs.get('rison', {}) + _args = kwargs.get("rison", {}) # handle select columns select_cols = _args.get(API_SELECT_COLUMNS_RIS_KEY, []) _pruned_select_cols = [col for col in select_cols if col in self.list_columns] @@ -1258,19 +1225,19 @@ def get_list(self, **kwargs): order_direction, page=page_index, page_size=page_size, - select_columns=query_select_columns + select_columns=query_select_columns, ) pks = self.datamodel.get_keys(lst) _response[API_RESULT_RES_KEY] = _list_model_schema.dump(lst, many=True).data - _response['ids'] = pks - _response['count'] = count + _response["ids"] = pks + _response["count"] = count self.pre_get_list(_response) return self.response(200, **_response) - @expose('/', methods=['POST']) + @expose("/", methods=["POST"]) @protect() @safe - @permission_name('post') + @permission_name("post") def post(self): """POST item to Model --- @@ -1304,7 +1271,7 @@ def post(self): $ref: '#/components/responses/500' """ if not request.is_json: - return self.response_400(message='Request is not JSON') + return self.response_400(message="Request is not JSON") try: item = self.add_model_schema.load(request.json) except ValidationError as err: @@ -1322,16 +1289,16 @@ def post(self): API_RESULT_RES_KEY: self.add_model_schema.dump( item.data, many=False ).data, - 'id': self.datamodel.get_pk_value(item.data) + "id": self.datamodel.get_pk_value(item.data), } ) except IntegrityError as e: return self.response_422(message=str(e.orig)) - @expose('/', methods=['PUT']) + @expose("/", methods=["PUT"]) @protect() @safe - @permission_name('put') + @permission_name("put") def put(self, pk): """POST item to Model --- @@ -1371,7 +1338,7 @@ def put(self, pk): """ item = self.datamodel.get(pk, self._base_filters) if not request.is_json: - return self.response(400, **{'message': 'Request is not JSON'}) + return self.response(400, **{"message": "Request is not JSON"}) if not item: return self.response_404() try: @@ -1388,17 +1355,19 @@ def put(self, pk): self.post_update(item) return self.response( 200, - **{API_RESULT_RES_KEY: self.edit_model_schema.dump( - item.data, - many=False).data} + **{ + API_RESULT_RES_KEY: self.edit_model_schema.dump( + item.data, many=False + ).data + } ) except IntegrityError as e: return self.response_422(message=str(e.orig)) - @expose('/', methods=['DELETE']) + @expose("/", methods=["DELETE"]) @protect() @safe - @permission_name('delete') + @permission_name("delete") def delete(self, pk): """Delete item from Model --- @@ -1432,7 +1401,7 @@ def delete(self, pk): try: self.datamodel.delete(item, raise_exception=True) self.post_delete(item) - return self.response(200, message='OK') + return self.response(200, message="OK") except IntegrityError as e: return self.response_422(message=str(e.orig)) @@ -1458,7 +1427,7 @@ def _handle_page_args(self, rison_args): def _sanitize_page_args(self, page, page_size): _page = page or 0 _page_size = page_size or self.page_size - max_page_size = current_app.config.get('FAB_API_MAX_PAGE_SIZE') + max_page_size = current_app.config.get("FAB_API_MAX_PAGE_SIZE") if _page_size > max_page_size or _page_size < 1: _page_size = max_page_size return _page, _page_size @@ -1471,12 +1440,12 @@ def _handle_order_args(self, rison_args): :param rison_args: :return: """ - order_column = rison_args.get(API_ORDER_COLUMN_RIS_KEY, '') - order_direction = rison_args.get(API_ORDER_DIRECTION_RIS_KEY, '') + order_column = rison_args.get(API_ORDER_COLUMN_RIS_KEY, "") + order_direction = rison_args.get(API_ORDER_DIRECTION_RIS_KEY, "") if not order_column and self.base_order: order_column, order_direction = self.base_order if order_column not in self.order_columns: - return '', '' + return "", "" return order_column, order_direction def _handle_filters_args(self, rison_args): @@ -1492,7 +1461,7 @@ def _description_columns_json(self, cols=None): cols = cols or [] d = {k: v for (k, v) in self.description_columns.items() if k in cols} for key, value in d.items(): - ret[key] = as_unicode(_(value).encode('UTF-8')) + ret[key] = as_unicode(_(value).encode("UTF-8")) return ret def _get_field_info(self, field, filter_rel_field, page=None, page_size=None): @@ -1504,25 +1473,21 @@ def _get_field_info(self, field, filter_rel_field, page=None, page_size=None): :return: dict with field details """ ret = dict() - ret['name'] = field.name - ret['label'] = self.label_columns.get(field.name, '') - ret['description'] = self.description_columns.get(field.name, '') + ret["name"] = field.name + ret["label"] = self.label_columns.get(field.name, "") + ret["description"] = self.description_columns.get(field.name, "") # Handles related fields if isinstance(field, Related) or isinstance(field, RelatedList): - ret['count'], ret['values'] = self._get_list_related_field( - field, - filter_rel_field, - - page=page, - page_size=page_size + ret["count"], ret["values"] = self._get_list_related_field( + field, filter_rel_field, page=page, page_size=page_size ) if field.validate and isinstance(field.validate, list): - ret['validate'] = [str(v) for v in field.validate] + ret["validate"] = [str(v) for v in field.validate] elif field.validate: - ret['validate'] = [str(field.validate)] - ret['type'] = field.__class__.__name__ - ret['required'] = field.required - ret['unique'] = field.unique + ret["validate"] = [str(field.validate)] + ret["type"] = field.__class__.__name__ + ret["required"] = field.required + ret["unique"] = field.unique return ret def _get_fields_info(self, cols, model_schema, filter_rel_fields, **kwargs): @@ -1544,15 +1509,19 @@ def _get_fields_info(self, cols, model_schema, filter_rel_fields, **kwargs): if col_args: page = col_args.get(API_PAGE_INDEX_RIS_KEY, None) page_size = col_args.get(API_PAGE_SIZE_RIS_KEY, None) - ret.append(self._get_field_info( - model_schema.fields[col], - filter_rel_fields.get(col, []), - page=page, - page_size=page_size - )) + ret.append( + self._get_field_info( + model_schema.fields[col], + filter_rel_fields.get(col, []), + page=page, + page_size=page_size, + ) + ) return ret - def _get_list_related_field(self, field, filter_rel_field, page=None, page_size=None): + def _get_list_related_field( + self, field, filter_rel_field, page=None, page_size=None + ): """ Return a list of values for a related field @@ -1565,31 +1534,20 @@ def _get_list_related_field(self, field, filter_rel_field, page=None, page_size= ret = list() if isinstance(field, Related) or isinstance(field, RelatedList): datamodel = self.datamodel.get_related_interface(field.name) - filters = datamodel.get_filters( - datamodel.get_search_columns_list() - ) + filters = datamodel.get_filters(datamodel.get_search_columns_list()) page, page_size = self._sanitize_page_args(page, page_size) order_field = self.order_rel_fields.get(field.name) if order_field: order_column, order_direction = order_field else: - order_column, order_direction = '', '' + order_column, order_direction = "", "" if filter_rel_field: filters = filters.add_filter_list(filter_rel_field) count, values = datamodel.query( - filters, - order_column, - order_direction, - page=page, - page_size=page_size, + filters, order_column, order_direction, page=page, page_size=page_size ) for value in values: - ret.append( - { - "id": datamodel.get_pk_value(value), - "value": str(value) - } - ) + ret.append({"id": datamodel.get_pk_value(value), "value": str(value)}) return count, ret def _merge_update_item(self, model_item, data): diff --git a/flask_appbuilder/api/convert.py b/flask_appbuilder/api/convert.py index 014ab37ce8..e8d0782338 100644 --- a/flask_appbuilder/api/convert.py +++ b/flask_appbuilder/api/convert.py @@ -1,7 +1,7 @@ from marshmallow import fields +from marshmallow_enum import EnumField from marshmallow_sqlalchemy import field_for from marshmallow_sqlalchemy.schema import ModelSchema -from marshmallow_enum import EnumField class BaseModel2SchemaConverter(object): diff --git a/flask_appbuilder/api/manager.py b/flask_appbuilder/api/manager.py index f9bc97fbd4..6aa62681fe 100644 --- a/flask_appbuilder/api/manager.py +++ b/flask_appbuilder/api/manager.py @@ -1,10 +1,10 @@ -from flask import current_app from apispec import APISpec from apispec.ext.marshmallow import MarshmallowPlugin -from flask_appbuilder.baseviews import BaseView +from flask import current_app from flask_appbuilder.api import BaseApi -from flask_appbuilder.api import expose, safe, protect +from flask_appbuilder.api import expose, protect, safe from flask_appbuilder.basemanager import BaseManager +from flask_appbuilder.baseviews import BaseView from flask_appbuilder.security.decorators import has_access diff --git a/flask_appbuilder/api/schemas.py b/flask_appbuilder/api/schemas.py index 9ddacc34bf..4b40441a1f 100644 --- a/flask_appbuilder/api/schemas.py +++ b/flask_appbuilder/api/schemas.py @@ -1,23 +1,23 @@ from ..const import ( - API_ORDER_COLUMNS_RIS_KEY, - API_LABEL_COLUMNS_RIS_KEY, - API_LIST_COLUMNS_RIS_KEY, - API_DESCRIPTION_COLUMNS_RIS_KEY, - API_SHOW_COLUMNS_RIS_KEY, API_ADD_COLUMNS_RIS_KEY, + API_ADD_TITLE_RIS_KEY, + API_DESCRIPTION_COLUMNS_RIS_KEY, API_EDIT_COLUMNS_RIS_KEY, - API_SELECT_COLUMNS_RIS_KEY, + API_EDIT_TITLE_RIS_KEY, API_FILTERS_RIS_KEY, - API_PERMISSIONS_RIS_KEY, - API_SELECT_KEYS_RIS_KEY, + API_LABEL_COLUMNS_RIS_KEY, + API_LIST_COLUMNS_RIS_KEY, + API_LIST_TITLE_RIS_KEY, API_ORDER_COLUMN_RIS_KEY, + API_ORDER_COLUMNS_RIS_KEY, API_ORDER_DIRECTION_RIS_KEY, API_PAGE_INDEX_RIS_KEY, API_PAGE_SIZE_RIS_KEY, - API_LIST_TITLE_RIS_KEY, - API_ADD_TITLE_RIS_KEY, - API_EDIT_TITLE_RIS_KEY, - API_SHOW_TITLE_RIS_KEY + API_PERMISSIONS_RIS_KEY, + API_SELECT_COLUMNS_RIS_KEY, + API_SELECT_KEYS_RIS_KEY, + API_SHOW_COLUMNS_RIS_KEY, + API_SHOW_TITLE_RIS_KEY, ) get_list_schema = { @@ -33,51 +33,33 @@ API_LABEL_COLUMNS_RIS_KEY, API_DESCRIPTION_COLUMNS_RIS_KEY, API_LIST_TITLE_RIS_KEY, - "none" - ] - } - }, - API_SELECT_COLUMNS_RIS_KEY: { - "type": "array", - "items": { - "type": "string" - } - }, - API_ORDER_COLUMN_RIS_KEY: { - "type": "string" - }, - API_ORDER_DIRECTION_RIS_KEY: { - "type": "string", - "enum": ["asc", "desc"] - }, - API_PAGE_INDEX_RIS_KEY: { - "type": "integer" - }, - API_PAGE_SIZE_RIS_KEY: { - "type": "integer" + "none", + ], + }, }, + API_SELECT_COLUMNS_RIS_KEY: {"type": "array", "items": {"type": "string"}}, + API_ORDER_COLUMN_RIS_KEY: {"type": "string"}, + API_ORDER_DIRECTION_RIS_KEY: {"type": "string", "enum": ["asc", "desc"]}, + API_PAGE_INDEX_RIS_KEY: {"type": "integer"}, + API_PAGE_SIZE_RIS_KEY: {"type": "integer"}, API_FILTERS_RIS_KEY: { "type": "array", "items": { "type": "object", "properties": { - "col": { - "type": "string" - }, - "opr": { - "type": "string" - }, + "col": {"type": "string"}, + "opr": {"type": "string"}, "value": { "anyOf": [ {"type": "number"}, {"type": "string"}, - {"type": "boolean"} + {"type": "boolean"}, ] - } - } - } - } - } + }, + }, + }, + }, + }, } get_item_schema = { @@ -92,17 +74,12 @@ API_DESCRIPTION_COLUMNS_RIS_KEY, API_LABEL_COLUMNS_RIS_KEY, API_SHOW_TITLE_RIS_KEY, - "none" - ] - } + "none", + ], + }, }, - API_SELECT_COLUMNS_RIS_KEY: { - "type": "array", - "items": { - "type": "string" - } - } - } + API_SELECT_COLUMNS_RIS_KEY: {"type": "array", "items": {"type": "string"}}, + }, } get_info_schema = { @@ -119,23 +96,19 @@ API_PERMISSIONS_RIS_KEY, API_ADD_TITLE_RIS_KEY, API_EDIT_TITLE_RIS_KEY, - "none" - ] - } + "none", + ], + }, }, API_ADD_COLUMNS_RIS_KEY: { "type": "object", "additionalProperties": { "type": "object", "properties": { - API_PAGE_SIZE_RIS_KEY: { - "type": "integer" - }, - API_PAGE_INDEX_RIS_KEY: { - "type": "integer" - } - } - } - } - } + API_PAGE_SIZE_RIS_KEY: {"type": "integer"}, + API_PAGE_INDEX_RIS_KEY: {"type": "integer"}, + }, + }, + }, + }, } diff --git a/flask_appbuilder/security/api.py b/flask_appbuilder/security/api.py index 6e6a855568..052d826037 100644 --- a/flask_appbuilder/security/api.py +++ b/flask_appbuilder/security/api.py @@ -1,25 +1,29 @@ from flask import request -from flask_jwt_extended import create_access_token, create_refresh_token, \ - jwt_refresh_token_required, get_jwt_identity -from flask_babel import lazy_gettext +from flask_jwt_extended import ( + create_access_token, + create_refresh_token, + get_jwt_identity, + jwt_refresh_token_required, +) + +from ..api import BaseApi, safe from ..const import ( - API_SECURITY_VERSION, - API_SECURITY_PROVIDER_DB, - API_SECURITY_PROVIDER_LDAP, - API_SECURITY_USERNAME_KEY, + API_SECURITY_ACCESS_TOKEN_KEY, API_SECURITY_PASSWORD_KEY, + API_SECURITY_PROVIDER_DB, API_SECURITY_PROVIDER_KEY, + API_SECURITY_PROVIDER_LDAP, API_SECURITY_REFRESH_KEY, - API_SECURITY_ACCESS_TOKEN_KEY, - API_SECURITY_REFRESH_TOKEN_KEY + API_SECURITY_REFRESH_TOKEN_KEY, + API_SECURITY_USERNAME_KEY, + API_SECURITY_VERSION, ) from ..views import expose -from ..api import BaseApi, ModelRestApi, safe class SecurityApi(BaseApi): - resource_name = 'security' + resource_name = "security" version = API_SECURITY_VERSION def add_apispec_components(self, api_spec): @@ -27,7 +31,7 @@ def add_apispec_components(self, api_spec): jwt_scheme = {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} api_spec.components.security_scheme("jwt", jwt_scheme) - @expose('/login', methods=['POST']) + @expose("/login", methods=["POST"]) @safe def login(self): """Login endpoint for the API returns a JWT and optionally a refresh token @@ -80,15 +84,9 @@ def login(self): return self.response_400(message="Missing required parameter") # AUTH if provider == API_SECURITY_PROVIDER_DB: - user = self.appbuilder.sm.auth_user_db( - username, - password - ) + user = self.appbuilder.sm.auth_user_db(username, password) elif provider == API_SECURITY_PROVIDER_LDAP: - user = self.appbuilder.sm.auth_user_ldap( - username, - password - ) + user = self.appbuilder.sm.auth_user_ldap(username, password) else: return self.response_400( message="Provider {} not supported".format(provider) @@ -98,14 +96,16 @@ def login(self): # Identity can be any data that is json serializable resp = dict() - resp[API_SECURITY_ACCESS_TOKEN_KEY] = \ - create_access_token(identity=user.id, fresh=True) + resp[API_SECURITY_ACCESS_TOKEN_KEY] = create_access_token( + identity=user.id, fresh=True + ) if refresh: - resp[API_SECURITY_REFRESH_TOKEN_KEY] = \ - create_refresh_token(identity=user.id) + resp[API_SECURITY_REFRESH_TOKEN_KEY] = create_refresh_token( + identity=user.id + ) return self.response(200, **resp) - @expose('/refresh', methods=['POST']) + @expose("/refresh", methods=["POST"]) @jwt_refresh_token_required @safe def refresh(self): From 421f7f055733ae3fcbade8cef18d525337bdcb5d Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 9 Apr 2019 23:16:39 +0100 Subject: [PATCH 107/109] [api] New, column dotted notation support --- flask_appbuilder/api/__init__.py | 2 +- flask_appbuilder/api/convert.py | 119 +++++++++++++++++----- flask_appbuilder/models/sqla/interface.py | 12 ++- flask_appbuilder/security/views.py | 41 -------- 4 files changed, 105 insertions(+), 69 deletions(-) diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index ccfdbdf19a..422c12158f 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -1417,7 +1417,7 @@ def _handle_page_args(self, rison_args): arguments, sets defaults and impose FAB_API_MAX_PAGE_SIZE - :param args: + :param rison_args: :return: (tuple) page, page_size """ page = rison_args.get(API_PAGE_INDEX_RIS_KEY, 0) diff --git a/flask_appbuilder/api/convert.py b/flask_appbuilder/api/convert.py index e8d0782338..2910c6d53d 100644 --- a/flask_appbuilder/api/convert.py +++ b/flask_appbuilder/api/convert.py @@ -4,6 +4,56 @@ from marshmallow_sqlalchemy.schema import ModelSchema +class TreeNode: + def __init__(self, data): + self.data = data + self.childs = list() + + def __repr__(self): + return "{}.{}".format(self.data, str(self.childs)) + + +class Tree: + """ + Simplistic one level Tree + """ + def __init__(self): + self.root = TreeNode('+') + + def add(self, data): + node = TreeNode(data) + self.root.childs.append(node) + + def add_child(self, parent, data): + node = TreeNode(data) + for n in self.root.childs: + if n.data == parent: + n.childs.append(node) + return + root = TreeNode(parent) + self.root.childs.append(root) + root.childs.append(node) + + def __repr__(self): + ret = "" + for node in self.root.childs: + ret += str(node) + return ret + + +def columns2Tree(columns): + tree = Tree() + for column in columns: + if '.' in column: + tree.add_child( + column.split('.')[0], + column.split('.')[1] + ) + else: + tree.add(column) + return tree + + class BaseModel2SchemaConverter(object): def __init__(self, datamodel, validators_columns): @@ -59,53 +109,72 @@ class Meta: return MetaSchema def _column2field(self, datamodel, column, nested=True, enum_dump_by_name=False): + """ + + :param datamodel: SQLAInterface + :param column: TreeNode column (childs are dotted column) + :param nested: Boolean if will create nested fields + :param enum_dump_by_name: + :return: Schema.field + """ _model = datamodel.obj # Handle relations - if datamodel.is_relation(column) and nested: - required = not datamodel.is_nullable(column) - nested_model = datamodel.get_related_model(column) + if datamodel.is_relation(column.data) and nested: + required = not datamodel.is_nullable(column.data) + nested_model = datamodel.get_related_model(column.data) + lst = [item.data for item in column.childs] nested_schema = self.convert( - [], + lst, nested_model, nested=False ) - if datamodel.is_relation_many_to_one(column): + if datamodel.is_relation_many_to_one(column.data): many = False - elif datamodel.is_relation_many_to_many(column): + elif datamodel.is_relation_many_to_many(column.data): many = True else: many = False field = fields.Nested(nested_schema, many=many, required=required) - field.unique = datamodel.is_unique(column) + field.unique = datamodel.is_unique(column.data) return field # Handle bug on marshmallow-sqlalchemy #163 - elif datamodel.is_relation(column): - required = not datamodel.is_nullable(column) - field = field_for(_model, column) + elif datamodel.is_relation(column.data): + required = not datamodel.is_nullable(column.data) + field = field_for(_model, column.data) field.required = required - field.unique = datamodel.is_unique(column) + field.unique = datamodel.is_unique(column.data) return field # Handle Enums - elif datamodel.is_enum(column): - required = not datamodel.is_nullable(column) - enum_class = datamodel.list_columns[column].info.get( + elif datamodel.is_enum(column.data): + required = not datamodel.is_nullable(column.data) + enum_class = datamodel.list_columns[column.data].info.get( 'enum_class', - datamodel.list_columns[column].type + datamodel.list_columns[column.data].type ) if enum_dump_by_name: enum_dump_by = EnumField.NAME else: enum_dump_by = EnumField.VALUE field = EnumField(enum_class, dump_by=enum_dump_by, required=required) - field.unique = datamodel.is_unique(column) + field.unique = datamodel.is_unique(column.data) return field - if not hasattr(getattr(_model, column), '__call__'): - field = field_for(_model, column) - field.unique = datamodel.is_unique(column) - if column in self.validators_columns: - field.validate.append(self.validators_columns[column]) + if not hasattr(getattr(_model, column.data), '__call__'): + field = field_for(_model, column.data) + field.unique = datamodel.is_unique(column.data) + if column.data in self.validators_columns: + field.validate.append(self.validators_columns[column.data]) return field + @staticmethod + def get_column_child_model(column): + if '.' in column: + return column.split('.')[0] + return column + + @staticmethod + def is_column_dotted(column): + return '.' in column + def convert(self, columns, model=None, nested=True, enum_dump_by_name=False): """ Creates a Marshmallow ModelSchema class @@ -131,14 +200,16 @@ class SchemaMixin: ma_sqla_fields_override = {} _columns = list() - for column in columns: - ma_sqla_fields_override[column] = self._column2field( + tree_columns = columns2Tree(columns) + for column in tree_columns.root.childs: + # Get child model is column is dotted notation + ma_sqla_fields_override[column.data] = self._column2field( _datamodel, column, nested, enum_dump_by_name ) - _columns.append(column) + _columns.append(column.data) for k, v in ma_sqla_fields_override.items(): setattr(SchemaMixin, k, v) return self._meta_schema_factory(_columns, _model, SchemaMixin)() diff --git a/flask_appbuilder/models/sqla/interface.py b/flask_appbuilder/models/sqla/interface.py index 7c8b68672d..62611332ca 100644 --- a/flask_appbuilder/models/sqla/interface.py +++ b/flask_appbuilder/models/sqla/interface.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import joinedload, load_only, Load, joinedload_all from sqlalchemy.exc import IntegrityError from sqlalchemy import func -from sqlalchemy.orm.properties import SynonymProperty +from sqlalchemy.orm.descriptor_props import SynonymProperty from ..base import BaseInterface from ..group import GroupByDateYear, GroupByDateMonth, GroupByCol @@ -63,6 +63,10 @@ def model_name(self): """ return self.obj.__name__ + @staticmethod + def is_model_already_joinded(query, model): + return model in [mapper.class_ for mapper in query._join_entities] + def _get_base_query(self, query=None, filters=None, order_column='', order_direction=''): if filters: @@ -93,7 +97,8 @@ def _query_select_options(self, query, select_columns=None): for column in select_columns: if '.' in column: model_relation = self.get_related_model(column.split('.')[0]) - query = query.join(model_relation) + if not self.is_model_already_joinded(query, model_relation): + query = query.join(model_relation) _load_options.append(Load(model_relation).load_only(column.split('.')[1])) else: if (not self.is_relation(column) and @@ -126,7 +131,8 @@ def query(self, filters=None, order_column='', order_direction='', tmp_order_column = '' for join_relation in order_column.split('.')[:-1]: model_relation = self.get_related_model(join_relation) - query = query.join(model_relation) + if not self.is_model_already_joinded(query, model_relation): + query = query.join(model_relation) # redefine order column name, because relationship can have a different name # from the related table name. tmp_order_column = tmp_order_column + model_relation.__tablename__ + '.' diff --git a/flask_appbuilder/security/views.py b/flask_appbuilder/security/views.py index 241f65bafc..dded810c31 100644 --- a/flask_appbuilder/security/views.py +++ b/flask_appbuilder/security/views.py @@ -23,47 +23,6 @@ log = logging.getLogger(__name__) -class SecurityApi(BaseApi): - - route_base = '/api/v1/security' - - @expose('/login', methods=['POST']) - @safe - def login(self): - """ - Login endpoint for the API returns a JWT - :return: - """ - if not request.is_json: - return self.response_400(message="Request payload is not JSON") - username = request.json.get('username', None) - password = request.json.get('password', None) - provider = request.json.get('provider', None) - if not username or not password or not provider: - return self.response_400(message="Missing required parameter") - # AUTH - if provider == 'db': - user = self.appbuilder.sm.auth_user_db( - username, - password - ) - elif provider == 'ldap': - user = self.appbuilder.sm.auth_user_ldap( - username, - password - ) - else: - return self.response_400( - message="Provider {} not supported".format(provider) - ) - if not user: - return self.response_401() - - # Identity can be any data that is json serializable - access_token = create_access_token(identity=user.id) - return self.response(200, access_token=access_token) - - class PermissionModelView(ModelView): route_base = '/permissions' base_permissions = ['can_list'] From ee854548ea8bffd635a80e4e5f6cbbd2d9668203 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 9 Apr 2019 23:27:31 +0100 Subject: [PATCH 108/109] [api] Fix, removed unused imports --- flask_appbuilder/models/sqla/interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_appbuilder/models/sqla/interface.py b/flask_appbuilder/models/sqla/interface.py index 62611332ca..9b76f74a57 100644 --- a/flask_appbuilder/models/sqla/interface.py +++ b/flask_appbuilder/models/sqla/interface.py @@ -4,7 +4,7 @@ import sqlalchemy as sa from . import filters -from sqlalchemy.orm import joinedload, load_only, Load, joinedload_all +from sqlalchemy.orm import joinedload, Load from sqlalchemy.exc import IntegrityError from sqlalchemy import func from sqlalchemy.orm.descriptor_props import SynonymProperty From 1bcf93a03538e66f77e25b6cee31078f013a93f3 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 9 Apr 2019 23:36:58 +0100 Subject: [PATCH 109/109] [api] [docs] New, column dotted notation support --- docs/rest_api.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/rest_api.rst b/docs/rest_api.rst index e82940686a..4ea9b857a5 100644 --- a/docs/rest_api.rst +++ b/docs/rest_api.rst @@ -1080,6 +1080,17 @@ this takes precedence over *Rison* arguments:: datamodel = SQLAInterface(Contact) list_columns = ['name', 'address'] +FAB supports dotted notation (one level on GET methods only) so you can control what columns get +rendered on related nested columns this applies with order by fields:: + + class ContactModelApi(ModelRestApi): + resource_name = 'contact' + datamodel = SQLAInterface(Contact) + list_columns = ['name', 'address', 'contact_group.name'] + +By default related columns on this case ``contact_group`` will create a nested +complete sub schema (on our example will return {"contact_group": {"name", "id"}}. + For ordering the results, the following will order contacts by name descending Z..A:: (order_column:name,order_direction:desc)