From 6c45a08f9c89387dfee6b3273e3f9fc7bedf4675 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Thu, 6 Jun 2024 12:54:23 +0100 Subject: [PATCH] fix m-m relation with m-o with doted notation --- examples/crud_rest_api/app/__init__.py | 16 ++++++++- examples/crud_rest_api/app/api.py | 16 +++++++-- examples/crud_rest_api/app/models.py | 40 ++++++++++++++++----- flask_appbuilder/__init__.py | 2 +- flask_appbuilder/base.py | 2 +- flask_appbuilder/models/sqla/interface.py | 42 +++++++++++++++++------ setup.py | 1 - 7 files changed, 94 insertions(+), 25 deletions(-) diff --git a/examples/crud_rest_api/app/__init__.py b/examples/crud_rest_api/app/__init__.py index fee1461188..a620923a22 100644 --- a/examples/crud_rest_api/app/__init__.py +++ b/examples/crud_rest_api/app/__init__.py @@ -1,7 +1,7 @@ from flask import Flask from flask_appbuilder.extensions import db -from .api import GreetingApi, ContactModelApi, GroupModelApi, ModelOMParentApi +from .api import GreetingApi, ContactModelApi, GroupModelApi, ModelOMParentApi, ContactGroupModelView, ContactGroupTagModelView from .extensions import appbuilder @@ -15,4 +15,18 @@ def create_app() -> Flask: appbuilder.add_api(ContactModelApi) appbuilder.add_api(GroupModelApi) appbuilder.add_api(ModelOMParentApi) + appbuilder.add_view( + ContactGroupModelView, + "List Contact Groups", + icon="fa-folder-open-o", + category="Contacts", + category_icon="fa-envelope", + ) + appbuilder.add_view( + ContactGroupTagModelView, + "List Contact Group Tags", + icon="fa-folder-open-o", + category="Contacts", + category_icon="fa-envelope", + ) return app diff --git a/examples/crud_rest_api/app/api.py b/examples/crud_rest_api/app/api.py index 1e27a9c6d2..872b17b8aa 100644 --- a/examples/crud_rest_api/app/api.py +++ b/examples/crud_rest_api/app/api.py @@ -1,16 +1,27 @@ -from flask_appbuilder import ModelRestApi +from flask_appbuilder import ModelRestApi, ModelView from flask_appbuilder.api import BaseApi, expose from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_appbuilder.models.filters import BaseFilter from marshmallow import fields, Schema from sqlalchemy import or_ -from .models import Contact, ContactGroup, ModelOMParent +from .models import Contact, ContactGroup, ModelOMParent, ContactGroupTag class GreetingsResponseSchema(Schema): message = fields.String() +class ContactGroupModelView(ModelView): + datamodel = SQLAInterface(ContactGroup) + list_columns = ["name", "created_by.first_name", "tags"] + add_columns = ["name", "tags"] + edit_columns = ["name", "tags"] + +class ContactGroupTagModelView(ModelView): + datamodel = SQLAInterface(ContactGroupTag) + add_columns = ["name"] + edit_columns = ["name"] + list_columns = ["name"] class GreetingApi(BaseApi): resource_name = "greeting" @@ -62,6 +73,7 @@ class GroupModelApi(ModelRestApi): resource_name = "group" datamodel = SQLAInterface(ContactGroup) allow_browser_login = True + list_columns = ["name", "tags.name", "created_by.first_name", "created_by.username"] class ModelOMParentApi(ModelRestApi): diff --git a/examples/crud_rest_api/app/models.py b/examples/crud_rest_api/app/models.py index 9825efb7bf..867cfd05d7 100644 --- a/examples/crud_rest_api/app/models.py +++ b/examples/crud_rest_api/app/models.py @@ -1,28 +1,50 @@ import datetime import enum +from typing import Optional, List from flask_appbuilder import Model -from flask_sqlalchemy.model import NameMixin -from sqlalchemy import Column, Date, ForeignKey, Integer, String, Enum -from sqlalchemy.orm import relationship, backref +from flask_appbuilder.models.mixins import AuditMixin +from sqlalchemy import Column, Date, ForeignKey, Integer, String, Enum, Table +from sqlalchemy.orm import relationship, backref, Mapped, mapped_column + mindate = datetime.date(datetime.MINYEAR, 1, 1) -class ContactGroup(NameMixin, Model): - id = Column(Integer, primary_key=True) - name = Column(String(50), unique=True, nullable=False) +class ContactGroup(AuditMixin, Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) def __repr__(self): return self.name +class ContactGroupTag(Model): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(150), unique=True, nullable=False) + group_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("contact_group.id"), nullable=True + ) + groups: Mapped[List[ContactGroup]] = relationship( + ContactGroup, + backref=backref("tags"), + secondary="group_tag_association" + ) + +# Association Table for N-N Relationship +group_tag_association = Table( + 'group_tag_association', + Model.metadata, + Column('group_id', Integer, ForeignKey('contact_group.id')), + Column('tag_id', Integer, ForeignKey('contact_group_tag.id')) +) + class Gender(enum.Enum): Female = 1 Male = 2 -class Contact(NameMixin, Model): +class Contact(Model): id = Column(Integer, primary_key=True) name = Column(String(150), unique=True, nullable=False) address = Column(String(564)) @@ -45,13 +67,13 @@ def year(self): return datetime.datetime(date.year, 1, 1) -class ModelOMParent(NameMixin, Model): +class ModelOMParent(Model): __tablename__ = "model_om_parent" id = Column(Integer, primary_key=True) field_string = Column(String(50), unique=True, nullable=False) -class ModelOMChild(NameMixin, Model): +class ModelOMChild(Model): id = Column(Integer, primary_key=True) field_string = Column(String(50), unique=True, nullable=False) parent_id = Column(Integer, ForeignKey("model_om_parent.id")) diff --git a/flask_appbuilder/__init__.py b/flask_appbuilder/__init__.py index 40e0f61916..7ab4e8ff52 100644 --- a/flask_appbuilder/__init__.py +++ b/flask_appbuilder/__init__.py @@ -1,5 +1,5 @@ __author__ = "Daniel Vaz Gaspar" -__version__ = "5.0.0rc1" +__version__ = "5.0.0a1" from .actions import action # noqa: F401 from .api import ModelRestApi # noqa: F401 diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index df0e1884ba..fda18dcce1 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -240,7 +240,7 @@ def post_init(self) -> None: self.add_permissions() @property - def app(self): + def app(self) -> Flask: log.warning( "appbuilder.app will be deprecated in future versions, " "use current_app instead" diff --git a/flask_appbuilder/models/sqla/interface.py b/flask_appbuilder/models/sqla/interface.py index f297957dd0..cbbed21032 100644 --- a/flask_appbuilder/models/sqla/interface.py +++ b/flask_appbuilder/models/sqla/interface.py @@ -5,7 +5,7 @@ from typing import Any, Iterable, Optional, Tuple, Type from flask import Request -from flask_appbuilder.exceptions import DatabaseException +from flask_appbuilder.exceptions import DatabaseException, FABException from flask_appbuilder.extensions import db from flask_appbuilder.filemanager import FileManager, ImageManager from flask_appbuilder.models.base import BaseInterface @@ -297,11 +297,25 @@ def apply_inner_select_joins( ) return query + def get_outer_query_from_inner_query( + self, query: Query, inner_query: Query + ) -> Query: + subquery = inner_query.subquery() + pk = self.get_pk() + pk_name = self.get_pk_name() + if isinstance(pk_name, str): + subquery_pk = getattr(subquery.c, pk_name) + return query.join(subquery, pk == subquery_pk) + if isinstance(pk_name, Iterable): + raise FABException("Composite primary key not supported") + raise FABException("No primary key found") + def apply_outer_select_joins( self, query: Query, select_columns: list[str] | None = None, outer_default_load: bool = False, + aliases_mapping: dict[str, AliasedClass] | None = None, ) -> Query: if not select_columns: return query @@ -331,7 +345,11 @@ def apply_outer_select_joins( .load_only(leaf_column) ) else: - query = query.options(Load(related_model).load_only(leaf_column)) + query = query.options( + Load(self.obj) + .joinedload(getattr(self.obj, root_relation)) + .load_only(leaf_column) + ) return query @@ -360,12 +378,13 @@ def get_inner_filters(self, filters: Optional[Filters]) -> Filters: def exists_col_to_many(self, select_columns: list[str]) -> bool: for column in select_columns: - if is_column_dotted(column): - root_relation = get_column_root_relation(column) - if self.is_relation_many_to_many( - root_relation - ) or self.is_relation_one_to_many(root_relation): - return True + if not is_column_dotted(column): + continue + root_relation = get_column_root_relation(column) + if self.is_relation_many_to_many( + root_relation + ) or self.is_relation_one_to_many(root_relation): + return True return False def get_alias_mapping( @@ -456,9 +475,12 @@ def apply_all( if select_columns and self.exists_col_to_many(select_columns): if select_columns and order_column: select_columns = select_columns + [order_column] - outer_query = inner_query._legacy_from_self() + outer_query = self.get_outer_query_from_inner_query(query, inner_query) outer_query = self.apply_outer_select_joins( - outer_query, select_columns, outer_default_load=outer_default_load + outer_query, + select_columns, + outer_default_load=outer_default_load, + aliases_mapping=aliases_mapping, ) return self.apply_order_by(outer_query, order_column, order_direction) else: diff --git a/setup.py b/setup.py index 05382c180f..c8cede3843 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,6 @@ def desc(): package_data={"": ["LICENSE"]}, entry_points={ "flask.commands": ["fab=flask_appbuilder.cli:fab"], - "console_scripts": ["fabmanager = flask_appbuilder.console:cli"], }, include_package_data=True, zip_safe=False,