Flask-Admin Hacks for Many-to-Many Relationships

Flask-Admin Many-to-Many Hacks

Flask-Admin is a pretty powerful administration solution, which, however lacks several important features for managing models with many-to-many relationships (or at least clear documentation thereof). Here are some useful (albeit perhaps unreliable) hacks to get Flask-Admin SQLAlchemy models to dance the dance.

The following model definitions are used in these examples:

class Post( Base ):
    __tablename__ = 'posts'
    id = Column( Integer, primary_key=True )
    title = Column( Unicode( 255 ), nullable=False )
    tags = relationship( 'Tag', backref='posts', secondary='taxonomy' )

class Tag( Base ):
    __tablename__ = 'tags'
    id = Column( Integer, primary_key=True )
    name = Column( Unicode( 255 ), nullable=False )

taxonomy = Table(
    'taxonomy', Base.metadata,
    Column( 'post_id', Integer, ForeignKey( 'posts.id' ) ),
    Column( 'tag_id', Integer, ForeignKey( 'tags.id' ) ),
)

Many-to-many Search

The relationship between a Post and a Tag is a MANYTOMANY one using the taxonomy table to keep track of the relations.

Searchable columns in a ModelView are defined via the column_searchable_list list property. This list is expected to contain defined columns. However, adding the Tag.name column to the list yields a nasty InvalidRequestError: Could not find a FROM clause to join from. Tried joining to tags, but got: Can't find any foreign key relationships between 'posts' and 'tags'. Exception. Why is this?

Well it turns out that Flask-Admin adds table definitions as joins as seen here. Thus the column.table (Tags.__table__ does not actually contain any relationship information, the definition does.

One way to fix this is to override the init_search method of the view and modify the _search_joins after super has been called. Like so:

    class PostModelView( ModelView ):
        column_searchable_list = ( Post.title, Tag.name )

        def init_search( self ):
            r = super( PostModelView, self ).init_search()
            self._search_joins['tags'] = Tag.name
            return r

This way the join used is actually a valid one, containing relationship information.

This looks like it’s something not taken into account in the core init_search method and fixing this is non-trivial.

Many-to-Many Inline Models

Inline models in Flask-Admin are displayed inline with the main models if the the two are related in some way. However “some” does not include a many-to-many relationship, providing a further headache.

An exception is raised when trying, saying: Cannot find reverse relation for model Tags.

Using a custom InlineModelConverter we’re able to add a MANYTOMANY inline form view.

Most of the code is taken from the default converter.

class PostModelViewInlineModelConverter( InlineModelConverter ):
    def contribute( self, converter, model, form_class, inline_model ):
        mapper = object_mapper( model() )
        target_mapper = object_mapper( inline_model() )

        info = self.get_info( inline_model )

        # Find reverse property
        for prop in target_mapper.iterate_properties:
            if hasattr( prop, 'direction' ) and prop.direction.name == 'MANYTOMANY':
                if issubclass( model, prop.mapper.class_ ):
                    reverse_prop = prop
                    break
        else:
            raise Exception( 'Cannot find reverse relation for model %s' % info.model )

        # Find forward property
        for prop in mapper.iterate_properties:
            if hasattr( prop, 'direction' ) and prop.direction.name == 'MANYTOMANY':
                if prop.mapper.class_ == target_mapper.class_:
                    forward_prop = prop
                    break
        else:
            raise Exception( 'Cannot find forward relation for model %s' % info.model )
        child_form = info.get_form()
        if child_form is None:
            child_form = get_form(
                info.model, converter,
                only=PostModelView.form_columns,
                exclude=PostModelView.form_excluded_columns,
                field_args=PostModelView.form_args,
                hidden_pk=True
            )
        child_form = info.postprocess_form( child_form )

        setattr( form_class, forward_prop.key + '_add', self.inline_field_list_type(
            child_form, self.session, info.model, reverse_prop.key, info
        ) )

        return form_class

This class should probably be a generic one, like M2MModelViewInlineModelConverter. Having injected a tags_add key into the form we continue to maintain the nice tagging UI provided by Flask-Admin, while bringing in an inline interface which allows us to create new tags from the posts form.

One final piece of the puzzle is to actually map tags_add to the model upon save, delete, modify actions, otherwise the fields have no clue on how to process the data. Luckily this is much easier than coding the converter.

The PostModelView‘s on_model_change has to look like this:

    def on_model_change( self, form, model ):
        form.tags_add.populate_obj( model, 'tags' )
        self.session.add( model )

Simple. Patching this up in core seems to also be something that takes a lot of thought, especially to keep both the fancy tagging UI and the create UI.

Overall, Flask-Admin is dead easy to work with on mainstream things, but other functionality takes a bit of tinkering to accomplish. Let the hacking continue…