Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Flask-Admin create view with SQLAlchemy context-sensitive functions

I have a data model which has a column that depends on other column values, following the instructions in this page I've created a context-sensitive function which is used to determine the value of this particular column on creation, something like this:

def get_column_value_from_context(context):
    # Instructions to produce value

    return value


class MyModel(db.Model):
    id = db.Column(db.Integer,
                   primary_key=True)
    my_column = db.Column(db.String(64),
                          nullable=False,
                          default=get_column_value_from_context)
    name = db.Column(db.String(32),
                     nullable=False,
                     unique=True,
                     index=True)
    title = db.Column(db.String(128),
                      nullable=False)
    description = db.Column(db.String(256),
                            nullable=False)

This approach works pretty decent, I can create rows without problems from the command line or using a script.

I've also added a ModelView to the app using Flask-Admin:

class MyModelView(ModelView):
    can_view_details = True
    can_set_page_size = True
    can_export = True


admin.add_view(MyModelView(MyModel, db.session))

This also works pretty decent until I click the Create button in the list view. I receive this error:

AttributeError: 'NoneType' object has no attribute 'get_current_parameters'

Because the implementation of the create_model handler in the ModelView is this:

def create_model(self, form):
    """
        Create model from form.
        :param form:
            Form instance
    """
    try:
        model = self.model()
        form.populate_obj(model)
        self.session.add(model)
        self._on_model_change(form, model, True)
        self.session.commit()
    except Exception as ex:
        if not self.handle_view_exception(ex):
            flash(gettext('Failed to create record. %(error)s', error=str(ex)), 'error')
            log.exception('Failed to create record.')

        self.session.rollback()

        return False
    else:
        self.after_model_change(form, model, True)


    return model

and here there isn't a context when the model is instantiated. So, I've created a custom view where the model instantiation in the creation handler could be redefined:

class CustomSQLAView(ModelView):
    def __init__(self, *args, **kwargs):
        super(CustomSQLAView, self).__init__(*args, **kwargs)

    def create_model(self, form):
        """
            Create model from form.
            :param form:
                Form instance
        """
        try:
            model = self.get_populated_model(form)
            self.session.add(model)
            self._on_model_change(form, model, True)
            self.session.commit()
        except Exception as ex:
            if not self.handle_view_exception(ex):
                flash(gettext('Failed to create record. %(error)s', error=str(ex)), 'error')
                log.exception('Failed to create record.')

            self.session.rollback()

            return False
        else:
            self.after_model_change(form, model, True)

        return model

    def get_populated_model(self, form):
        model = self.model()
        form.populate_obj(model)

        return model

Now I can redefine the get_populated_model method to instantiate the model in the usual way:

class MyModelView(CustomSQLAView):
    can_view_details = True
    can_set_page_size = True
    can_export = True

    def get_populated_model(self, form):
        model = self.model(
            name=form.name.data,
            title=form.title.data,
            description=form.description.data,
        )

        return model

Despite that this works, I suspect it breaks something. Flask-Admin has several implementation of the populate_obj method of forms and fields, so I would like to keep everything safe.

What is the proper way to do this?

like image 959
osjerick Avatar asked Jun 28 '18 05:06

osjerick


1 Answers

So there are several factors which come in to play at this question.

But first, a small fully functional app to illustrate:

from flask_admin.contrib.sqla import ModelView
from flask_sqlalchemy import SQLAlchemy
from flask_admin import Admin
from flask import Flask

app = Flask(__name__)
db = SQLAlchemy(app)
admin = Admin(app)
app.secret_key = 'arstartartsar'

def get_column_value_from_context(context):
    print('getting value from context...')
    if context.isinsert:
        return 'its an insert!'
    else:
        return 'aww, its not an insert'

class MyModel(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    my_column = db.Column(db.String(64), nullable=False, default=get_column_value_from_context)
    name = db.Column(db.String(32), nullable=False, unique=True, index=True)

class MyModelView(ModelView):
    form_excluded_columns = ['my_column']

db.create_all()
admin.add_view(MyModelView(MyModel, db.session))
print('1')
x = MyModel(name='hey')
print('2')
db.session.add(x)
print('3')
db.session.commit()
print('4')

When we fire up this app, what's printed is:

1
2
3
getting value from context...
4

So, only after committing, does the get_column_value_from_context function fire up. When, in the create view, you 'create' a new model, you don't have a context yet because you're not committing anything to the database yet. You only get the context to set the default when you're committing your instance to the database! That why, in the source code of flask admin, this happens:

if getattr(default, 'is_callable', False):
    value = lambda: default.arg(None)  # noqa: E731

They check if the default you specified is a function, and if so, they call it without context (because there is no context yet!).

You have several options to overcome this depending on your goals:

1) Only calculate the value of my_column when adding it to the database:

Simply ignore the field in the form, and it will be determined when you add it to the db.

class MyModelView(ModelView):
    form_excluded_columns = ['my_column']

2) Only calculate it when adding it to the db, but making it editable afterwards:

class MyModelView(ModelView):
    form_edit_rules = ['name', 'my_column']
    form_create_rules = ['name']
like image 125
Joost Avatar answered Nov 08 '22 17:11

Joost