pallets-eco / flask-admin

Simple and extensible administrative interface framework for Flask
https://flask-admin.readthedocs.io
BSD 3-Clause "New" or "Revised" License
5.8k stars 1.58k forks source link

[Feature Request] Allow association_proxy/relationship fields in `editable_column_list` #2210

Open caffeinatedMike opened 2 years ago

caffeinatedMike commented 2 years ago

Can anyone provide a way to make an association_proxy value work in editable_column_list? I'd like to have a boolean field from a related table displayed in list view. I did find this StackOverflow question from almost 5 years ago that asks about the same thing, but the solution does not work for me (and I'm assuming a lot has changed with flask-admin and sqlalchemy since then).

Unfortunately, in my attempts, I keep running into this error in the _convert_relation function.

Traceback (most recent call last):
  File "C:\Program Files\JetBrains\PyCharm Community Edition 2021.3\plugins\python-ce\helpers\pydev\pydevd.py", line 1483, in _exec
    pydev_imports.execfile(file, globals, locals)  # execute the script
  File "C:\Program Files\JetBrains\PyCharm Community Edition 2021.3\plugins\python-ce\helpers\pydev\_pydev_imps\_pydev_execfile.py", line 18, in execfile
    exec(compile(contents+"\n", file, 'exec'), glob, loc)
  File "C:/Users/mhill/PycharmProjects/daap_reporting_portal/wsgi.py", line 3, in <module>
    app = create_app()
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\app\__init__.py", line 27, in create_app
    register_admin_components()
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\app\__init__.py", line 57, in register_admin_components
    admin.add_view(ReporterView(Reporter, db.session, name="Reporters", endpoint="reporters"))
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\app\admin\view.py", line 50, in __init__
    super().__init__(*args, **kwargs)
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\venv\lib\site-packages\flask_admin\contrib\sqla\view.py", line 330, in __init__
    menu_icon_value=menu_icon_value)
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\venv\lib\site-packages\flask_admin\model\base.py", line 817, in __init__
    self._refresh_cache()
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\venv\lib\site-packages\flask_admin\model\base.py", line 909, in _refresh_cache
    self._refresh_forms_cache()
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\venv\lib\site-packages\flask_admin\model\base.py", line 841, in _refresh_forms_cache
    self._list_form_class = self.get_list_form()
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\venv\lib\site-packages\flask_admin\model\base.py", line 1282, in get_list_form
    return self.scaffold_list_form(validators=validators)
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\venv\lib\site-packages\flask_admin\contrib\sqla\view.py", line 773, in scaffold_list_form
    field_args=validators)
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\venv\lib\site-packages\flask_admin\contrib\sqla\form.py", line 556, in get_form
    field = converter.convert(model, mapper, name, prop, field_args.get(name), hidden_pk)
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\venv\lib\site-packages\flask_admin\contrib\sqla\form.py", line 153, in convert
    return self._convert_relation(name, prop, property_is_association_proxy, kwargs)
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\venv\lib\site-packages\flask_admin\contrib\sqla\form.py", line 97, in _convert_relation
    remote_model = prop.mapper.class_
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\venv\lib\site-packages\sqlalchemy\util\langhelpers.py", line 1240, in __getattr__
    return self._fallback_getattr(key)
  File "C:\Users\mhill\PycharmProjects\daap_reporting_portal\venv\lib\site-packages\sqlalchemy\util\langhelpers.py", line 1214, in _fallback_getattr
    raise AttributeError(key)
AttributeError: mapper

Here is a minimal, reproducible example

from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm.decl_api import declarative_mixin
from sqlalchemy.orm.relationships import foreign
from sqlalchemy.sql import and_

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_admin import Admin

app = Flask("MWE")
db = SQLAlchemy(app)
admin = Admin(app)

@declarative_mixin
class EntityRefMixin:
    entity_id = db.Column(db.Integer, nullable=False)
    entity_type = db.Column(
        db.Enum("reporter", "account", "report", "job", name="entity_type_enum"),
        nullable=False
    )

@declarative_mixin
class EntityRelationshipMixin:
    @classmethod
    def join_builder(cls, remote_cls):
        return and_(
            foreign(remote_cls.entity_type) == cls.__tablename__,
            foreign(remote_cls.entity_id) == cls.id
        )

    @declared_attr
    def gs(cls):  # noqa
        return db.relationship(
            "GoogleStorage",
            primaryjoin=lambda: cls.join_builder(GoogleStorage),
            backref=cls.__tablename__,
            overlaps=f"reporter,account,gs,{cls.__tablename__}",
            uselist=False  # only a single entry will be related back to the record
        )

    @declared_attr
    def use_gs(self):
        return association_proxy("gs", "use_gs")

class GoogleStorage(EntityRefMixin, db.Model):
    __tablename__ = "google_storage"
    id = db.Column(db.Integer, primary_key=True)
    use_gs = db.Column(db.Boolean)
    bucket_name = db.Column(db.String(50), nullable=True)
    folder_path = db.Column(db.String(100), nullable=True)

class Reporter(EntityRelationshipMixin, db.Model):
    __tablename__ = "reporter"
    id = db.Column(db.Integer, primary_key=True)
    module = db.Column(db.String(50), nullable=False)

class ReporterView(ModelView):
    column_list = ("module", "use_gs")  # I've also tried gs.use_gs, but neither is editable
    column_editable_list = column_list
    column_filters = column_list

with app.app_context():
    db.create_all()

admin.add_view(ReporterView(Reporter, db.session, name="Reporters", endpoint="reporters"))

if __name__ == "__main__":
    app.run(debug=True)
caffeinatedMike commented 2 years ago

I've been able to add BooleanField support of association proxies with a bit of hackiness.

flask_admin/contrib/sqla/form.py - AdminModelConverter

    def _convert_relation(self, name, prop, property_is_association_proxy, kwargs):
        # Check if relation is specified
        form_columns = getattr(self.view, 'form_columns', None)
        if form_columns and name not in form_columns:
            return None

        try:  # newly-added
            remote_model = prop.mapper.class_
            column = prop.local_remote_pairs[0][0]

            # If this relation points to local column that's not foreign key, assume
            # that it is backref and use remote column data
            if not column.foreign_keys:
                column = prop.local_remote_pairs[0][1]
        except AttributeError:                            # newly-added
            remote_model = prop.parent.class_  # newly-added
            column = prop.expression                 # newly-added

        kwargs['label'] = self._get_label(name, kwargs)
        kwargs['description'] = self._get_description(name, kwargs)

        # determine optional/required, or respect existing
        requirement_options = (validators.Optional, validators.InputRequired)
        requirement_validator_specified = any(isinstance(v, requirement_options) for v in kwargs['validators'])
        if property_is_association_proxy or column.nullable or prop.direction.name != 'MANYTOONE':
            kwargs['allow_blank'] = True
            if not requirement_validator_specified:
                kwargs['validators'].append(validators.Optional())
        else:
            kwargs['allow_blank'] = False
            if not requirement_validator_specified:
                kwargs['validators'].append(validators.InputRequired())

        # Override field type if necessary
        override = self._get_field_override(prop.key)
        if override:
            if override is fields.BooleanField:  # newly-added
                kwargs.pop("allow_blank")       # newly-added
            return override(**kwargs)

        multiple = (property_is_association_proxy or
                    (prop.direction.name in ('ONETOMANY', 'MANYTOMANY') and prop.uselist))
        return self._model_select_field(prop, multiple, remote_model, **kwargs)

Then, making sure to add a creator function to my association proxy

@declarative_mixin
class EntityRelationshipMixin:
    @classmethod
    def join_builder(cls, remote_cls):
        return and_(
            foreign(remote_cls.entity_type) == cls.__tablename__,
            foreign(remote_cls.entity_id) == cls.id
        )

    @declared_attr
    def gs(cls):
        return db.relationship(
            "GoogleStorage",
            primaryjoin=lambda: cls.join_builder(GoogleStorage),
            backref=cls.__tablename__,
            overlaps=f"reporter,account,gs,{cls.__tablename__}",
            uselist=False  # only a single entry will be related back to the record
        )

    @declared_attr
    def use_gs(cls):
        return association_proxy(
            "gs",
            "use_gs",
            creator=lambda value: GoogleStorage(
                use_gs=value,
                entity_type=cls.__tablename__,
                entity_id=cls.id
            )
        )

And finally, adding the association fields to form_overrides, assigning the appropriate field type

class ReporterView(ModelView):
    column_list = ("module", "use_gs")
    column_editable_list = column_list
    column_filters = column_list
    form_overrides = {"use_gs": BooleanField}
caffeinatedMike commented 2 years ago

@michaelbukachi Any interest in having a look at "properly" implementing better support for specifying association/relationship fields in column_editable_list?

michaelbukachi commented 2 years ago

@caffeinatedMike sure thing. I'll take this up.

caffeinatedMike commented 2 years ago

Hey @michaelbukachi Any luck with this? I'm finding a need for it with relationships more than association proxies now.