import enum
import os
import re
import typing as t
import uuid
from datetime import date
from datetime import datetime
from datetime import time
from typing import Any

import arrow
import pytest
from sqlalchemy import Boolean
from sqlalchemy import cast
from sqlalchemy import Column
from sqlalchemy import Date
from sqlalchemy import DateTime
from sqlalchemy import Enum
from sqlalchemy import Float
from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Table
from sqlalchemy import Text
from sqlalchemy import Time
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import backref
from sqlalchemy.orm import relationship
from sqlalchemy_utils import ArrowType
from sqlalchemy_utils import ChoiceType
from sqlalchemy_utils import ColorType
from sqlalchemy_utils import CurrencyType
from sqlalchemy_utils import EmailType
from sqlalchemy_utils import IPAddressType
from sqlalchemy_utils import URLType
from sqlalchemy_utils import UUIDType
from wtforms import fields
from wtforms import PasswordField
from wtforms import validators

from flask_admin import Admin
from flask_admin import form
from flask_admin._compat import as_unicode
from flask_admin._compat import iteritems
from flask_admin.contrib.fileadmin import FileAdmin
from flask_admin.contrib.sqla import filters
from flask_admin.contrib.sqla import ModelView
from flask_admin.contrib.sqla import tools
from flask_admin.form.fields import DateTimeField
from flask_admin.form.fields import Select2Field
from flask_admin.tests import flask_babel_test_decorator
from flask_admin.tests.conftest import skip_or_return_session_or_db


class CustomModelView(ModelView):
    def __init__(
        self,
        model,
        session,
        name=None,
        category=None,
        endpoint=None,
        url=None,
        static_folder=None,
        menu_class_name=None,
        menu_icon_type=None,
        menu_icon_value=None,
        **kwargs,
    ):
        for k, v in iteritems(kwargs):
            setattr(self, k, v)

        super().__init__(
            model,
            session,
            name,
            category,
            endpoint,
            url,
            static_folder,
            menu_class_name,
            menu_icon_type,
            menu_icon_value,
        )

    form_choices = {"choice_field": [("choice-1", "One"), ("choice-2", "Two")]}


def create_models(sqla_db_ext):
    class Model1(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
        __tablename__ = "model1"

        def __init__(
            self,
            test1=None,
            test2=None,
            test3=None,
            test4=None,
            bool_field=False,
            date_field=None,
            time_field=None,
            datetime_field=None,
            email_field=None,
            choice_field=None,
            enum_field=None,
            enum_type_field=None,
        ):
            self.test1 = test1
            self.test2 = test2
            self.test3 = test3
            self.test4 = test4
            self.bool_field = bool_field
            self.date_field = date_field
            self.time_field = time_field
            self.datetime_field = datetime_field
            self.email_field = email_field
            self.choice_field = choice_field
            self.enum_field = enum_field
            self.enum_type_field = enum_type_field

        class EnumChoices(enum.Enum):
            first = 1
            second = 2

        id = Column(Integer, primary_key=True)
        test1 = Column(String(20))
        test2 = Column(String(20))
        test3 = Column(Text)
        test4 = Column(Text)
        bool_field = Column(Boolean)
        date_field = Column(Date)
        time_field = Column(Time)
        datetime_field = Column(DateTime)
        email_field = Column(EmailType)
        enum_field = Column(Enum("model1_v1", "model1_v2"), nullable=True)
        enum_type_field = Column(Enum(EnumChoices), nullable=True)
        choice_field = Column(String, nullable=True)
        sqla_utils_choice = Column(
            ChoiceType([("choice-1", "First choice"), ("choice-2", "Second choice")])
        )
        sqla_utils_enum = Column(ChoiceType(EnumChoices, impl=Integer()))
        sqla_utils_arrow = Column(ArrowType, default=arrow.utcnow())
        sqla_utils_uuid = Column(UUIDType(binary=False), default=uuid.uuid4)
        sqla_utils_url = Column(URLType)
        sqla_utils_ip_address = Column(IPAddressType)
        sqla_utils_currency = Column(CurrencyType)
        sqla_utils_color = Column(ColorType)

        def __str__(self):
            return self.test1

    class Model2(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
        __tablename__ = "model2"

        def __init__(
            self,
            string_field=None,
            int_field=None,
            bool_field=None,
            model1=None,
            float_field=None,
            string_field_default=None,
            string_field_empty_default=None,
        ):
            self.string_field = string_field
            self.int_field = int_field
            self.bool_field = bool_field
            self.model1 = model1
            self.float_field = float_field
            self.string_field_default = string_field_default
            self.string_field_empty_default = string_field_empty_default

        id = Column(Integer, primary_key=True)
        string_field = Column(String)
        string_field_default = Column(Text, nullable=False, default="")
        string_field_empty_default = Column(Text, nullable=False, default="")
        int_field = Column(Integer)
        bool_field = Column(Boolean)
        enum_field: t.Any = Column(Enum("model2_v1", "model2_v2"), nullable=True)
        float_field = Column(Float)

        # Relation
        model1_id = Column(Integer, ForeignKey(Model1.id))
        model1 = relationship(lambda: Model1, backref="model2")

    sqla_db_ext.create_all()

    return Model1, Model2


def fill_db(sqla_db_ext, Model1, Model2):
    model1_obj1 = Model1("test1_val_1", "test2_val_1", bool_field=True)
    model1_obj2 = Model1("test1_val_2", "test2_val_2", bool_field=False)
    model1_obj3 = Model1("test1_val_3", "test2_val_3")
    model1_obj4 = Model1(
        "test1_val_4",
        "test2_val_4",
        email_field="test@test.com",
        choice_field="choice-1",
    )

    model2_obj1 = Model2("test2_val_1", model1=model1_obj1, float_field=None)
    model2_obj2 = Model2("test2_val_2", model1=model1_obj2, float_field=None)
    model2_obj3 = Model2("test2_val_3", int_field=5000, float_field=25.9)
    model2_obj4 = Model2("test2_val_4", int_field=9000, float_field=75.5)
    model2_obj5 = Model2("test2_val_5", int_field=6169453081680413441)

    date_obj1 = Model1("date_obj1", date_field=date(2014, 11, 17))
    date_obj2 = Model1("date_obj2", date_field=date(2013, 10, 16))
    timeonly_obj1 = Model1("timeonly_obj1", time_field=time(11, 10, 9))
    timeonly_obj2 = Model1("timeonly_obj2", time_field=time(10, 9, 8))
    datetime_obj1 = Model1(
        "datetime_obj1", datetime_field=datetime(2014, 4, 3, 1, 9, 0)
    )
    datetime_obj2 = Model1(
        "datetime_obj2", datetime_field=datetime(2013, 3, 2, 0, 8, 0)
    )

    enum_obj1 = Model1("enum_obj1", enum_field="model1_v1")
    enum_obj2 = Model1("enum_obj2", enum_field="model1_v2")

    enum_type_obj1 = Model1("enum_type_obj1", enum_type_field=Model1.EnumChoices.first)
    enum_type_obj2 = Model1("enum_type_obj2", enum_type_field=Model1.EnumChoices.second)

    empty_obj = Model1(test2="empty_obj")

    sqla_db_ext.db.session.add_all(
        [
            model1_obj1,
            model1_obj2,
            model1_obj3,
            model1_obj4,
            model2_obj1,
            model2_obj2,
            model2_obj3,
            model2_obj4,
            model2_obj5,
            date_obj1,
            timeonly_obj1,
            datetime_obj1,
            date_obj2,
            timeonly_obj2,
            datetime_obj2,
            enum_obj1,
            enum_obj2,
            enum_type_obj1,
            enum_type_obj2,
            empty_obj,
        ]
    )
    sqla_db_ext.db.session.commit()


@pytest.mark.filterwarnings(
    "ignore:'iter_groups' is expected to return 4 items tuple since wtforms 3.1, this "
    "will be mandatory in wtforms 3.2:DeprecationWarning",
)
def test_model(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(Model1, param)

        admin.add_view(view)

        assert view.model == Model1
        assert view.name == "Model1"
        assert view.endpoint == "model1"

        assert view._primary_key == "id"

        assert "test1" in view._sortable_columns
        assert "test2" in view._sortable_columns
        assert "test3" in view._sortable_columns
        assert "test4" in view._sortable_columns

        assert view._create_form_class is not None
        assert view._edit_form_class is not None
        assert not view._search_supported
        assert view._filters is None

        # Verify form
        assert view._create_form_class.test1.field_class == fields.StringField  # type: ignore[attr-defined]
        assert view._create_form_class.test2.field_class == fields.StringField  # type: ignore[attr-defined]
        assert view._create_form_class.test3.field_class == fields.TextAreaField  # type: ignore[attr-defined]
        assert view._create_form_class.test4.field_class == fields.TextAreaField  # type: ignore[attr-defined]
        assert view._create_form_class.email_field.field_class == fields.StringField  # type: ignore[attr-defined]
        assert view._create_form_class.choice_field.field_class == Select2Field  # type: ignore[attr-defined]
        assert view._create_form_class.enum_field.field_class == Select2Field  # type: ignore[attr-defined]
        assert view._create_form_class.sqla_utils_choice.field_class == Select2Field  # type: ignore[attr-defined]
        assert view._create_form_class.sqla_utils_enum.field_class == Select2Field  # type: ignore[attr-defined]
        assert view._create_form_class.sqla_utils_arrow.field_class == DateTimeField  # type: ignore[attr-defined]
        assert view._create_form_class.sqla_utils_uuid.field_class == fields.StringField  # type: ignore[attr-defined]
        assert view._create_form_class.sqla_utils_url.field_class == fields.StringField  # type: ignore[attr-defined]
        assert (
            view._create_form_class.sqla_utils_ip_address.field_class  # type: ignore[attr-defined]
            == fields.StringField
        )
        assert (
            view._create_form_class.sqla_utils_currency.field_class  # type: ignore[attr-defined]
            == fields.StringField
        )
        assert (
            view._create_form_class.sqla_utils_color.field_class == fields.StringField  # type: ignore[attr-defined]
        )

        # Make some test clients
        client = app.test_client()

        # check that we can retrieve a list view
        rv = client.get("/admin/model1/")
        assert rv.status_code == 200

        # check that we can retrieve a 'create' view
        rv = client.get("/admin/model1/new/")
        assert rv.status_code == 200

        # create a new record
        uuid_obj = uuid.uuid4()
        rv = client.post(
            "/admin/model1/new/",
            data=dict(
                test1="test1large",
                test2="test2",
                time_field=time(0, 0, 0),
                email_field="Test@TEST.com",
                choice_field="choice-1",
                enum_field="model1_v1",
                sqla_utils_choice="choice-1",
                sqla_utils_enum=1,
                sqla_utils_arrow="2018-10-27 14:17:00",
                sqla_utils_uuid=str(uuid_obj),
                sqla_utils_url="http://www.example.com",
                sqla_utils_ip_address="127.0.0.1",
                sqla_utils_currency="USD",
                sqla_utils_color="#f0f0f0",
            ),
        )
        assert rv.status_code == 302

        # check that the new record was persisted
        model = sqla_db_ext.db.session.query(Model1).first()
        assert model.test1 == "test1large"
        assert model.test2 == "test2"
        assert model.test3 is None
        assert model.test4 is None
        assert model.email_field == "test@test.com"
        assert model.choice_field == "choice-1"
        assert model.enum_field == "model1_v1"
        assert model.sqla_utils_choice == "choice-1"
        assert model.sqla_utils_enum.value == 1
        assert model.sqla_utils_arrow == arrow.get("2018-10-27 14:17:00")
        assert model.sqla_utils_uuid == uuid_obj
        assert model.sqla_utils_url == "http://www.example.com"
        assert str(model.sqla_utils_ip_address) == "127.0.0.1"
        assert str(model.sqla_utils_currency) == "USD"
        assert model.sqla_utils_color.hex == "#f0f0f0"

        # check that the new record shows up on the list view
        rv = client.get("/admin/model1/")
        assert rv.status_code == 200
        assert "test1large" in rv.data.decode("utf-8")

        # check that we can retrieve an edit view
        url = f"/admin/model1/edit/?id={model.id}"
        rv = client.get(url)
        assert rv.status_code == 200

        # verify that midnight does not show as blank
        assert "00:00:00" in rv.data.decode("utf-8")

        # edit the record
        new_uuid_obj = uuid.uuid4()
        rv = client.post(
            url,
            data=dict(
                test1="test1small",
                test2="test2large",
                email_field="Test2@TEST.com",
                choice_field="__None",
                enum_field="__None",
                sqla_utils_choice="__None",
                sqla_utils_enum="__None",
                sqla_utils_arrow="",
                sqla_utils_uuid=str(new_uuid_obj),
                sqla_utils_url="",
                sqla_utils_ip_address="",
                sqla_utils_currency="",
                sqla_utils_color="",
            ),
        )
        assert rv.status_code == 302

        # check that the changes were persisted
        model = sqla_db_ext.db.session.query(Model1).first()
        assert model.test1 == "test1small"
        assert model.test2 == "test2large"
        assert model.test3 is None
        assert model.test4 is None
        assert model.email_field == "test2@test.com"
        assert model.choice_field is None
        assert model.enum_field is None
        assert model.sqla_utils_choice is None
        assert model.sqla_utils_enum is None
        assert model.sqla_utils_arrow is None
        assert model.sqla_utils_uuid == new_uuid_obj
        assert model.sqla_utils_url is None
        assert model.sqla_utils_ip_address is None
        assert model.sqla_utils_currency is None
        assert model.sqla_utils_color is None

        # check that the model can be deleted
        url = f"/admin/model1/delete/?id={model.id}"
        rv = client.post(url)
        assert rv.status_code == 302
        assert sqla_db_ext.db.session.query(Model1).count() == 0


@pytest.mark.xfail(raises=Exception)
def test_no_pk(app, sqla_db_ext, admin, session_or_db):
    class Model(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
        __tablename__ = "model"
        test = Column(Integer)

    param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
    view = CustomModelView(Model, param)
    admin.add_view(view)


def test_list_columns(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        # test column_list with a list of strings
        view = CustomModelView(
            Model1,
            param,
            column_list=["test1", "test3"],
            column_labels=dict(test1="Column1"),
        )
        admin.add_view(view)

        # test column_list with a list of SQLAlchemy columns
        view2 = CustomModelView(
            Model1,
            param,
            endpoint="model1_2",
            column_list=[Model1.test1, Model1.test3],
            column_labels=dict(test1="Column1"),
        )
        admin.add_view(view2)

        assert len(view._list_columns) == 2
        assert view._list_columns == [("test1", "Column1"), ("test3", "Test3")]

        client = app.test_client()

        rv = client.get("/admin/model1/")
        data = rv.data.decode("utf-8")
        assert "Column1" in data
        assert "Test2" not in data

        assert len(view2._list_columns) == 2
        assert view2._list_columns == [("test1", "Column1"), ("test3", "Test3")]

        rv = client.get("/admin/model1_2/")
        data = rv.data.decode("utf-8")
        assert "Column1" in data
        assert "Test2" not in data


def test_complex_list_columns(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        M1, M2 = create_models(sqla_db_ext)

        m1 = M1("model1_val1")
        sqla_db_ext.db.session.add(m1)
        sqla_db_ext.db.session.add(M2("model2_val1", model1=m1))

        sqla_db_ext.db.session.commit()

        # test column_list with a list of strings on a relation
        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(M2, param, column_list=["model1.test1"])
        admin.add_view(view)

        client = app.test_client()

        rv = client.get("/admin/model2/")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "model1_val1" in data


def test_exclude_columns(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model1,
            param,
            column_exclude_list=[
                "test2",
                "test4",
                "enum_field",
                "enum_type_field",
                "date_field",
                "datetime_field",
                "time_field",
                "sqla_utils_choice",
                "sqla_utils_enum",
                "sqla_utils_arrow",
                "sqla_utils_uuid",
                "sqla_utils_url",
                "sqla_utils_ip_address",
                "sqla_utils_currency",
                "sqla_utils_color",
            ],
        )
        admin.add_view(view)

        assert view._list_columns == [
            ("test1", "Test1"),
            ("test3", "Test3"),
            ("bool_field", "Bool Field"),
            ("email_field", "Email Field"),
            ("choice_field", "Choice Field"),
        ]

        client = app.test_client()

        rv = client.get("/admin/model1/")
        data = rv.data.decode("utf-8")
        assert "Test1" in data
        assert "Test2" not in data


def test_column_searchable_list(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model2, param, column_searchable_list=["string_field", "int_field"]
        )
        admin.add_view(view)

        assert view._search_supported
        assert view._search_fields
        assert len(view._search_fields) == 2

        assert isinstance(view._search_fields[0][0], Column)
        assert isinstance(view._search_fields[1][0], Column)
        assert view._search_fields[0][0].name == "string_field"
        assert view._search_fields[1][0].name == "int_field"

        sqla_db_ext.db.session.add(Model2("model1-test", 5000))
        sqla_db_ext.db.session.add(Model2("model2-test", 9000))
        sqla_db_ext.db.session.commit()

        client = app.test_client()

        rv = client.get("/admin/model2/?search=model1")
        data = rv.data.decode("utf-8")
        assert "model1-test" in data
        assert "model2-test" not in data

        rv = client.get("/admin/model2/?search=9000")
        data = rv.data.decode("utf-8")
        assert "model1-test" not in data
        assert "model2-test" in data


def test_extra_args_search(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view1 = CustomModelView(
            Model1,
            param,
            column_searchable_list=[
                "test1",
            ],
        )

        admin.add_view(view1)

        sqla_db_ext.db.session.add(
            Model2(
                "model1-test",
            )
        )
        sqla_db_ext.db.session.commit()

        client = app.test_client()

        # check that extra args in the url are propagated as hidden fields in the
        # search form
        rv = client.get("/admin/model1/?search=model1&foo=bar")
        data = rv.data.decode("utf-8")
        assert '<input type="hidden" name="foo" value="bar">' in data


def test_extra_args_filter(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view2 = CustomModelView(
            Model2,
            param,
            column_filters=[
                "int_field",
            ],
        )
        admin.add_view(view2)

        sqla_db_ext.db.session.add(Model2("model2-test", 5000))
        sqla_db_ext.db.session.commit()

        client = app.test_client()

        # check that extra args in the url are propagated as hidden fields in the  form
        rv = client.get("/admin/model2/?flt1_0=5000&foo=bar")
        data = rv.data.decode("utf-8")
        assert '<input type="hidden" name="foo" value="bar">' in data


def test_complex_searchable_list(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(Model2, param, column_searchable_list=["model1.test1"])
        admin.add_view(view)
        view2 = CustomModelView(
            Model1, param, column_searchable_list=[Model2.string_field]
        )
        admin.add_view(view2)

        m1 = Model1("model1-test1-val")
        m2 = Model1("model1-test2-val")
        sqla_db_ext.db.session.add(m1)
        sqla_db_ext.db.session.add(m2)
        sqla_db_ext.db.session.add(Model2("model2-test1-val", model1=m1))
        sqla_db_ext.db.session.add(Model2("model2-test2-val", model1=m2))
        sqla_db_ext.db.session.commit()

        client = app.test_client()

        # test relation string - 'model1.test1'
        rv = client.get("/admin/model2/?search=model1-test1")
        data = rv.data.decode("utf-8")
        assert "model2-test1-val" in data
        assert "model2-test2-val" not in data

        # test relation object - Model2.string_field
        rv = client.get("/admin/model1/?search=model2-test1")
        data = rv.data.decode("utf-8")
        assert "model1-test1-val" in data
        assert "model1-test2-val" not in data


def test_complex_searchable_list_missing_children(
    app, sqla_db_ext, admin, session_or_db
):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model1, param, column_searchable_list=["test1", "model2.string_field"]
        )
        admin.add_view(view)

        sqla_db_ext.db.session.add(Model1("magic string"))
        sqla_db_ext.db.session.commit()

        client = app.test_client()

        rv = client.get("/admin/model1/?search=magic")
        data = rv.data.decode("utf-8")
        assert "magic string" in data


def test_column_editable_list(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model1, param, column_editable_list=["test1", "enum_field"]
        )
        admin.add_view(view)

        # Test in-line editing for relations
        view = CustomModelView(Model2, param, column_editable_list=["model1"])
        admin.add_view(view)

        fill_db(sqla_db_ext, Model1, Model2)

        client = app.test_client()

        # Test in-line edit field rendering
        rv = client.get("/admin/model1/")
        data = rv.data.decode("utf-8")
        assert 'data-role="x-editable"' in data

        # Form - Test basic in-line edit functionality
        rv = client.post(
            "/admin/model1/ajax/update/",
            data={
                "list_form_pk": "1",
                "test1": "change-success-1",
            },
        )
        data = rv.data.decode("utf-8")
        assert "Record was successfully saved." == data

        # ensure the value has changed
        rv = client.get("/admin/model1/")
        data = rv.data.decode("utf-8")
        assert "change-success-1" in data

        # Test validation error
        rv = client.post(
            "/admin/model1/ajax/update/",
            data={
                "list_form_pk": "1",
                "enum_field": "problematic-input",
            },
        )
        assert rv.status_code == 500

        # Test invalid primary key
        rv = client.post(
            "/admin/model1/ajax/update/",
            data={
                "list_form_pk": "1000",
                "test1": "problematic-input",
            },
        )
        data = rv.data.decode("utf-8")
        assert rv.status_code == 500

        # Test editing column not in column_editable_list
        rv = client.post(
            "/admin/model1/ajax/update/",
            data={
                "list_form_pk": "1",
                "test2": "problematic-input",
            },
        )
        data = rv.data.decode("utf-8")
        assert "problematic-input" not in data

        rv = client.post(
            "/admin/model2/ajax/update/",
            data={
                "list_form_pk": "1",
                "model1": "3",
            },
        )
        data = rv.data.decode("utf-8")
        assert "Record was successfully saved." == data

        # confirm the value has changed
        rv = client.get("/admin/model2/")
        data = rv.data.decode("utf-8")
        assert "test1_val_3" in data


def test_details_view(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view_no_details = CustomModelView(Model1, param)
        admin.add_view(view_no_details)

        # fields are scaffolded
        view_w_details = CustomModelView(Model2, param, can_view_details=True)
        admin.add_view(view_w_details)

        # show only specific fields in details w/ column_details_list
        string_field_view = CustomModelView(
            Model2,
            param,
            can_view_details=True,
            column_details_list=["string_field"],
            endpoint="sf_view",
        )
        admin.add_view(string_field_view)

        fill_db(sqla_db_ext, Model1, Model2)

        client = app.test_client()

        # ensure link to details is hidden when can_view_details is disabled
        rv = client.get("/admin/model1/")
        data = rv.data.decode("utf-8")
        assert "/admin/model1/details/" not in data

        # ensure link to details view appears
        rv = client.get("/admin/model2/")
        data = rv.data.decode("utf-8")
        assert "/admin/model2/details/" in data

        # test redirection when details are disabled
        rv = client.get("/admin/model1/details/?url=%2Fadmin%2Fmodel1%2F&id=1")
        assert rv.status_code == 302

        # test if correct data appears in details view when enabled
        rv = client.get("/admin/model2/details/?url=%2Fadmin%2Fmodel2%2F&id=1")
        data = rv.data.decode("utf-8")
        assert "String Field" in data
        assert "test2_val_1" in data
        assert "test1_val_1" in data

        # test column_details_list
        rv = client.get("/admin/sf_view/details/?url=%2Fadmin%2Fsf_view%2F&id=1")
        data = rv.data.decode("utf-8")
        assert "String Field" in data
        assert "test2_val_1" in data
        assert "test1_val_1" not in data


def test_editable_list_special_pks(app, sqla_db_ext, admin, session_or_db):
    """Tests editable list view + a primary key with special characters"""
    with app.app_context():

        class Model1(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model1"

            def __init__(self, id=None, val1=None):
                self.id = id
                self.val1 = val1

            id = Column(String(20), primary_key=True)  # type: ignore[assignment]
            val1 = Column(String(20))

        sqla_db_ext.create_all()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(Model1, param, column_editable_list=["val1"])
        admin.add_view(view)

        sqla_db_ext.db.session.add(Model1("1-1", "test1"))
        sqla_db_ext.db.session.add(Model1("1-5", "test2"))
        sqla_db_ext.db.session.commit()

        client = app.test_client()

        # Form - Test basic in-line edit functionality
        rv = client.post(
            "/admin/model1/ajax/update/",
            data={
                "list_form_pk": "1-1",
                "val1": "change-success-1",
            },
        )
        data = rv.data.decode("utf-8")
        assert "Record was successfully saved." == data

        # ensure the value has changed
        rv = client.get("/admin/model1/")
        data = rv.data.decode("utf-8")
        assert "change-success-1" in data


def test_column_filters(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view1 = CustomModelView(Model1, param, column_filters=["test1"])
        admin.add_view(view1)

        client = app.test_client()
        assert view1._filters
        assert len(view1._filters) == 7

        # Generate views
        view2 = CustomModelView(Model2, param, column_filters=["model1"])

        view5 = CustomModelView(
            Model1, param, column_filters=["test1"], endpoint="_strings"
        )
        admin.add_view(view5)

        view6 = CustomModelView(Model2, param, column_filters=["int_field"])
        admin.add_view(view6)

        view7 = CustomModelView(
            Model1, param, column_filters=["bool_field"], endpoint="_bools"
        )
        admin.add_view(view7)

        view8 = CustomModelView(
            Model2, param, column_filters=["float_field"], endpoint="_float"
        )
        admin.add_view(view8)

        view9 = CustomModelView(
            Model2,
            param,
            endpoint="_model2",
            column_filters=["model1.bool_field"],
            column_list=[
                "string_field",
                "model1.id",
                "model1.bool_field",
            ],
        )
        admin.add_view(view9)

        view10 = CustomModelView(
            Model1,
            param,
            column_filters=["test1"],
            endpoint="_model3",
            named_filter_urls=True,
        )
        admin.add_view(view10)

        view11 = CustomModelView(
            Model1,
            param,
            column_filters=["date_field", "datetime_field", "time_field"],
            endpoint="_datetime",
        )
        admin.add_view(view11)

        view12 = CustomModelView(
            Model1, param, column_filters=["enum_field"], endpoint="_enumfield"
        )
        admin.add_view(view12)

        view13 = CustomModelView(
            Model2,
            param,
            column_filters=[filters.FilterEqual(Model1.test1, "Test1")],
            endpoint="_relation_test",
        )
        admin.add_view(view13)

        view14 = CustomModelView(
            Model1,
            param,
            column_filters=["enum_type_field"],
            endpoint="_enumtypefield",
        )
        admin.add_view(view14)
        # Test views
        assert view1._filter_groups
        assert [
            (f["index"], f["operation"]) for f in view1._filter_groups["Test1"]
        ] == [
            (0, "contains"),
            (1, "not contains"),
            (2, "equals"),
            (3, "not equal"),
            (4, "empty"),
            (5, "in list"),
            (6, "not in list"),
        ]
        assert view2._filter_groups
        # Test filter that references property0
        assert [
            (f["index"], f["operation"]) for f in view2._filter_groups["Model1 / Test1"]
        ] == [
            (0, "contains"),
            (1, "not contains"),
            (2, "equals"),
            (3, "not equal"),
            (4, "empty"),
            (5, "in list"),
            (6, "not in list"),
        ]

        assert [
            (f["index"], f["operation"]) for f in view2._filter_groups["Model1 / Test2"]
        ] == [
            (7, "contains"),
            (8, "not contains"),
            (9, "equals"),
            (10, "not equal"),
            (11, "empty"),
            (12, "in list"),
            (13, "not in list"),
        ]

        assert [
            (f["index"], f["operation"]) for f in view2._filter_groups["Model1 / Test3"]
        ] == [
            (14, "contains"),
            (15, "not contains"),
            (16, "equals"),
            (17, "not equal"),
            (18, "empty"),
            (19, "in list"),
            (20, "not in list"),
        ]

        assert [
            (f["index"], f["operation"]) for f in view2._filter_groups["Model1 / Test4"]
        ] == [
            (21, "contains"),
            (22, "not contains"),
            (23, "equals"),
            (24, "not equal"),
            (25, "empty"),
            (26, "in list"),
            (27, "not in list"),
        ]

        assert [
            (f["index"], f["operation"])
            for f in view2._filter_groups["Model1 / Bool Field"]
        ] == [
            (28, "equals"),
            (29, "not equal"),
        ]

        assert [
            (f["index"], f["operation"])
            for f in view2._filter_groups["Model1 / Date Field"]
        ] == [
            (30, "equals"),
            (31, "not equal"),
            (32, "greater than"),
            (33, "smaller than"),
            (34, "between"),
            (35, "not between"),
            (36, "empty"),
        ]

        assert [
            (f["index"], f["operation"])
            for f in view2._filter_groups["Model1 / Time Field"]
        ] == [
            (37, "equals"),
            (38, "not equal"),
            (39, "greater than"),
            (40, "smaller than"),
            (41, "between"),
            (42, "not between"),
            (43, "empty"),
        ]

        assert [
            (f["index"], f["operation"])
            for f in view2._filter_groups["Model1 / Datetime Field"]
        ] == [
            (44, "equals"),
            (45, "not equal"),
            (46, "greater than"),
            (47, "smaller than"),
            (48, "between"),
            (49, "not between"),
            (50, "empty"),
        ]

        assert [
            (f["index"], f["operation"])
            for f in view2._filter_groups["Model1 / Email Field"]
        ] == [
            (51, "contains"),
            (52, "not contains"),
            (53, "equals"),
            (54, "not equal"),
            (55, "empty"),
            (56, "in list"),
            (57, "not in list"),
        ]

        assert [
            (f["index"], f["operation"])
            for f in view2._filter_groups["Model1 / Enum Field"]
        ] == [
            (58, "equals"),
            (59, "not equal"),
            (60, "empty"),
            (61, "in list"),
            (62, "not in list"),
        ]

        assert [
            (f["index"], f["operation"])
            for f in view2._filter_groups["Model1 / Enum Type Field"]
        ] == [
            (63, "equals"),
            (64, "not equal"),
            (65, "empty"),
            (66, "in list"),
            (67, "not in list"),
        ]

        assert [
            (f["index"], f["operation"])
            for f in view2._filter_groups["Model1 / Choice Field"]
        ] == [
            (68, "contains"),
            (69, "not contains"),
            (70, "equals"),
            (71, "not equal"),
            (72, "empty"),
            (73, "in list"),
            (74, "not in list"),
        ]

        assert [
            (f["index"], f["operation"])
            for f in view2._filter_groups["Model1 / Sqla Utils Choice"]
        ] == [
            (75, "equals"),
            (76, "not equal"),
            (77, "contains"),
            (78, "not contains"),
            (79, "empty"),
        ]

        assert [
            (f["index"], f["operation"])
            for f in view2._filter_groups["Model1 / Sqla Utils Enum"]
        ] == [
            (80, "equals"),
            (81, "not equal"),
            (82, "contains"),
            (83, "not contains"),
            (84, "empty"),
        ]

        # Test filter with a dot
        view3 = CustomModelView(Model2, param, column_filters=["model1.bool_field"])
        assert view3._filter_groups
        assert [
            (f["index"], f["operation"])
            for f in view3._filter_groups["model1 / Model1 / Bool Field"]
        ] == [
            (0, "equals"),
            (1, "not equal"),
        ]

        # Test column_labels on filters
        view4 = CustomModelView(
            Model2,
            param,
            column_filters=["model1.bool_field", "string_field"],
            column_labels={
                "model1.bool_field": "Test Filter #1",
                "string_field": "Test Filter #2",
            },
        )
        assert view4._filter_groups
        assert list(view4._filter_groups.keys()) == ["Test Filter #1", "Test Filter #2"]

        fill_db(sqla_db_ext, Model1, Model2)

        # Test equals
        rv = client.get("/admin/model1/?flt0_0=test1_val_1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        # the filter value is always in "data"
        # need to check a different column than test1 for the expected row

        assert "test2_val_1" in data
        assert "test1_val_2" not in data

        # Test NOT IN filter
        rv = client.get("/admin/model1/?flt0_6=test1_val_1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")

        assert "test1_val_2" in data
        assert "test2_val_1" not in data
        # Test string filter
        assert view5._filter_groups
        assert [
            (f["index"], f["operation"]) for f in view5._filter_groups["Test1"]
        ] == [
            (0, "contains"),
            (1, "not contains"),
            (2, "equals"),
            (3, "not equal"),
            (4, "empty"),
            (5, "in list"),
            (6, "not in list"),
        ]

        # string - equals
        rv = client.get("/admin/_strings/?flt0_0=test1_val_1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" in data
        assert "test1_val_2" not in data

        # string - not equal
        rv = client.get("/admin/_strings/?flt0_1=test1_val_1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" not in data
        assert "test1_val_2" in data

        # string - contains
        rv = client.get("/admin/_strings/?flt0_2=test1_val_1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" in data
        assert "test1_val_2" not in data

        # string - not contains
        rv = client.get("/admin/_strings/?flt0_3=test1_val_1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" not in data
        assert "test1_val_2" in data

        # string - empty
        rv = client.get("/admin/_strings/?flt0_4=1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "empty_obj" in data
        assert "test1_val_1" not in data
        assert "test1_val_2" not in data

        # string - not empty
        rv = client.get("/admin/_strings/?flt0_4=0")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "empty_obj" not in data
        assert "test1_val_1" in data
        assert "test1_val_2" in data

        # string - in list
        rv = client.get("/admin/_strings/?flt0_5=test1_val_1%2Ctest1_val_2")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" in data
        assert "test2_val_2" in data
        assert "test1_val_3" not in data
        assert "test1_val_4" not in data

        # string - not in list
        rv = client.get("/admin/_strings/?flt0_6=test1_val_1%2Ctest1_val_2")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" not in data
        assert "test2_val_2" not in data
        assert "test1_val_3" in data
        assert "test1_val_4" in data
        # Test integer filter
        assert view6._filter_groups
        assert [
            (f["index"], f["operation"]) for f in view6._filter_groups["Int Field"]
        ] == [
            (0, "equals"),
            (1, "not equal"),
            (2, "greater than"),
            (3, "smaller than"),
            (4, "empty"),
            (5, "in list"),
            (6, "not in list"),
        ]

        # integer - equals
        rv = client.get("/admin/model2/?flt0_0=5000")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_3" in data
        assert "test2_val_4" not in data

        # integer - equals (huge number)
        rv = client.get("/admin/model2/?flt0_0=6169453081680413441")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_5" in data
        assert "test2_val_4" not in data

        # integer - equals - test validation
        rv = client.get("/admin/model2/?flt0_0=badval")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "Invalid Filter Value" in data

        # integer - not equal
        rv = client.get("/admin/model2/?flt0_1=5000")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_3" not in data
        assert "test2_val_4" in data

        # integer - greater
        rv = client.get("/admin/model2/?flt0_2=6000")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_3" not in data
        assert "test2_val_4" in data

        # integer - smaller
        rv = client.get("/admin/model2/?flt0_3=6000")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_3" in data
        assert "test2_val_4" not in data

        # integer - empty
        rv = client.get("/admin/model2/?flt0_4=1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" in data
        assert "test2_val_2" in data
        assert "test2_val_3" not in data
        assert "test2_val_4" not in data

        # integer - not empty
        rv = client.get("/admin/model2/?flt0_4=0")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" not in data
        assert "test2_val_2" not in data
        assert "test2_val_3" in data
        assert "test2_val_4" in data

        # integer - in list
        rv = client.get("/admin/model2/?flt0_5=5000%2C9000")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" not in data
        assert "test2_val_2" not in data
        assert "test2_val_3" in data
        assert "test2_val_4" in data

        # integer - in list (huge number)
        rv = client.get("/admin/model2/?flt0_5=6169453081680413441")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" not in data
        assert "test2_val_5" in data

        # integer - in list - test validation
        rv = client.get("/admin/model2/?flt0_5=5000%2Cbadval")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "Invalid Filter Value" in data

        # integer - not in list
        rv = client.get("/admin/model2/?flt0_6=5000%2C9000")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" in data
        assert "test2_val_2" in data
        assert "test2_val_3" not in data
        assert "test2_val_4" not in data
        # Test boolean filter
        assert view7._filter_groups
        assert [
            (f["index"], f["operation"]) for f in view7._filter_groups["Bool Field"]
        ] == [
            (0, "equals"),
            (1, "not equal"),
        ]

        # boolean - equals - Yes
        rv = client.get("/admin/_bools/?flt0_0=1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" in data
        assert "test2_val_2" not in data
        assert "test2_val_3" not in data

        # boolean - equals - No
        rv = client.get("/admin/_bools/?flt0_0=0")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" not in data
        assert "test2_val_2" in data
        assert "test2_val_3" in data

        # boolean - not equals - Yes
        rv = client.get("/admin/_bools/?flt0_1=1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" not in data
        assert "test2_val_2" in data
        assert "test2_val_3" in data

        # boolean - not equals - No
        rv = client.get("/admin/_bools/?flt0_1=0")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" in data
        assert "test2_val_2" not in data
        assert "test2_val_3" not in data
        # Test float filter
        assert view8._filter_groups
        assert [
            (f["index"], f["operation"]) for f in view8._filter_groups["Float Field"]
        ] == [
            (0, "equals"),
            (1, "not equal"),
            (2, "greater than"),
            (3, "smaller than"),
            (4, "empty"),
            (5, "in list"),
            (6, "not in list"),
        ]

        # float - equals
        rv = client.get("/admin/_float/?flt0_0=25.9")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_3" in data
        assert "test2_val_4" not in data

        # float - equals - test validation
        rv = client.get("/admin/_float/?flt0_0=badval")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "Invalid Filter Value" in data

        # float - not equal
        rv = client.get("/admin/_float/?flt0_1=25.9")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_3" not in data
        assert "test2_val_4" in data

        # float - greater
        rv = client.get("/admin/_float/?flt0_2=60.5")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_3" not in data
        assert "test2_val_4" in data

        # float - smaller
        rv = client.get("/admin/_float/?flt0_3=60.5")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_3" in data
        assert "test2_val_4" not in data

        # float - empty
        rv = client.get("/admin/_float/?flt0_4=1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" in data
        assert "test2_val_2" in data
        assert "test2_val_3" not in data
        assert "test2_val_4" not in data

        # float - not empty
        rv = client.get("/admin/_float/?flt0_4=0")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" not in data
        assert "test2_val_2" not in data
        assert "test2_val_3" in data
        assert "test2_val_4" in data

        # float - in list
        rv = client.get("/admin/_float/?flt0_5=25.9%2C75.5")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" not in data
        assert "test2_val_2" not in data
        assert "test2_val_3" in data
        assert "test2_val_4" in data

        # float - in list - test validation
        rv = client.get("/admin/_float/?flt0_5=25.9%2Cbadval")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "Invalid Filter Value" in data

        # float - not in list
        rv = client.get("/admin/_float/?flt0_6=25.9%2C75.5")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" in data
        assert "test2_val_2" in data
        assert "test2_val_3" not in data
        assert "test2_val_4" not in data

        # Test filters to joined table field
        rv = client.get("/admin/_model2/?flt1_0=1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" in data
        assert "test2_val_2" not in data
        assert "test2_val_3" not in data
        assert "test2_val_4" not in data

        # Test human readable URLs
        rv = client.get("/admin/_model3/?flt1_test1_equals=test1_val_1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" in data
        assert "test1_val_2" not in data
        # Test date, time, and datetime filters
        assert view11._filter_groups
        assert [
            (f["index"], f["operation"]) for f in view11._filter_groups["Date Field"]
        ] == [
            (0, "equals"),
            (1, "not equal"),
            (2, "greater than"),
            (3, "smaller than"),
            (4, "between"),
            (5, "not between"),
            (6, "empty"),
        ]

        assert [
            (f["index"], f["operation"])
            for f in view11._filter_groups["Datetime Field"]
        ] == [
            (7, "equals"),
            (8, "not equal"),
            (9, "greater than"),
            (10, "smaller than"),
            (11, "between"),
            (12, "not between"),
            (13, "empty"),
        ]

        assert [
            (f["index"], f["operation"]) for f in view11._filter_groups["Time Field"]
        ] == [
            (14, "equals"),
            (15, "not equal"),
            (16, "greater than"),
            (17, "smaller than"),
            (18, "between"),
            (19, "not between"),
            (20, "empty"),
        ]

        # date - equals
        rv = client.get("/admin/_datetime/?flt0_0=2014-11-17")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "date_obj1" in data
        assert "date_obj2" not in data

        # date - not equal
        rv = client.get("/admin/_datetime/?flt0_1=2014-11-17")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "date_obj1" not in data
        assert "date_obj2" in data

        # date - greater
        rv = client.get("/admin/_datetime/?flt0_2=2014-11-16")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "date_obj1" in data
        assert "date_obj2" not in data

        # date - smaller
        rv = client.get("/admin/_datetime/?flt0_3=2014-11-16")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "date_obj1" not in data
        assert "date_obj2" in data

        # date - between
        rv = client.get("/admin/_datetime/?flt0_4=2014-11-13+to+2014-11-20")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "date_obj1" in data
        assert "date_obj2" not in data

        # date - not between
        rv = client.get("/admin/_datetime/?flt0_5=2014-11-13+to+2014-11-20")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "date_obj1" not in data
        assert "date_obj2" in data

        # date - empty
        rv = client.get("/admin/_datetime/?flt0_6=1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" in data
        assert "date_obj1" not in data
        assert "date_obj2" not in data

        # date - empty
        rv = client.get("/admin/_datetime/?flt0_6=0")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" not in data
        assert "date_obj1" in data
        assert "date_obj2" in data

        # datetime - equals
        rv = client.get("/admin/_datetime/?flt0_7=2014-04-03+01%3A09%3A00")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "datetime_obj1" in data
        assert "datetime_obj2" not in data

        # datetime - not equal
        rv = client.get("/admin/_datetime/?flt0_8=2014-04-03+01%3A09%3A00")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "datetime_obj1" not in data
        assert "datetime_obj2" in data

        # datetime - greater
        rv = client.get("/admin/_datetime/?flt0_9=2014-04-03+01%3A08%3A00")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "datetime_obj1" in data
        assert "datetime_obj2" not in data

        # datetime - smaller
        rv = client.get("/admin/_datetime/?flt0_10=2014-04-03+01%3A08%3A00")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "datetime_obj1" not in data
        assert "datetime_obj2" in data

        # datetime - between
        rv = client.get(
            "/admin/_datetime"
            "/?flt0_11=2014-04-02+00%3A00%3A00+to+2014-11-20+23%3A59%3A59"
        )
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "datetime_obj1" in data
        assert "datetime_obj2" not in data

        # datetime - not between
        rv = client.get(
            "/admin/_datetime"
            "/?flt0_12=2014-04-02+00%3A00%3A00+to+2014-11-20+23%3A59%3A59"
        )
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "datetime_obj1" not in data
        assert "datetime_obj2" in data

        # datetime - empty
        rv = client.get("/admin/_datetime/?flt0_13=1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" in data
        assert "datetime_obj1" not in data
        assert "datetime_obj2" not in data

        # datetime - not empty
        rv = client.get("/admin/_datetime/?flt0_13=0")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" not in data
        assert "datetime_obj1" in data
        assert "datetime_obj2" in data

        # time - equals
        rv = client.get("/admin/_datetime/?flt0_14=11%3A10%3A09")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "timeonly_obj1" in data
        assert "timeonly_obj2" not in data

        # time - not equal
        rv = client.get("/admin/_datetime/?flt0_15=11%3A10%3A09")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "timeonly_obj1" not in data
        assert "timeonly_obj2" in data

        # time - greater
        rv = client.get("/admin/_datetime/?flt0_16=11%3A09%3A09")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "timeonly_obj1" in data
        assert "timeonly_obj2" not in data

        # time - smaller
        rv = client.get("/admin/_datetime/?flt0_17=11%3A09%3A09")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "timeonly_obj1" not in data
        assert "timeonly_obj2" in data

        # time - between
        rv = client.get("/admin/_datetime/?flt0_18=10%3A40%3A00+to+11%3A50%3A59")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "timeonly_obj1" in data
        assert "timeonly_obj2" not in data

        # time - not between
        rv = client.get("/admin/_datetime/?flt0_19=10%3A40%3A00+to+11%3A50%3A59")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "timeonly_obj1" not in data
        assert "timeonly_obj2" in data

        # time - empty
        rv = client.get("/admin/_datetime/?flt0_20=1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" in data
        assert "timeonly_obj1" not in data
        assert "timeonly_obj2" not in data

        # time - not empty
        rv = client.get("/admin/_datetime/?flt0_20=0")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" not in data
        assert "timeonly_obj1" in data
        assert "timeonly_obj2" in data

        # Test enum filter
        # enum - equals
        rv = client.get("/admin/_enumfield/?flt0_0=model1_v1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "enum_obj1" in data
        assert "enum_obj2" not in data

        # enum - not equal
        rv = client.get("/admin/_enumfield/?flt0_1=model1_v1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "enum_obj1" not in data
        assert "enum_obj2" in data

        # enum - empty
        rv = client.get("/admin/_enumfield/?flt0_2=1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" in data
        assert "enum_obj1" not in data
        assert "enum_obj2" not in data

        # enum - not empty
        rv = client.get("/admin/_enumfield/?flt0_2=0")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" not in data
        assert "enum_obj1" in data
        assert "enum_obj2" in data

        # enum - in list
        rv = client.get("/admin/_enumfield/?flt0_3=model1_v1%2Cmodel1_v2")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" not in data
        assert "enum_obj1" in data
        assert "enum_obj2" in data

        # enum - not in list
        rv = client.get("/admin/_enumfield/?flt0_4=model1_v1%2Cmodel1_v2")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" in data
        assert "enum_obj1" not in data
        assert "enum_obj2" not in data

        # Test enum type filter
        # enum type - equals
        rv = client.get("/admin/_enumtypefield/?flt0_0=first")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "enum_type_obj1" in data
        assert "enum_type_obj2" not in data

        # enum - not equal
        rv = client.get("/admin/_enumtypefield/?flt0_1=first")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "enum_type_obj1" not in data
        assert "enum_type_obj2" in data

        # enum - empty
        rv = client.get("/admin/_enumtypefield/?flt0_2=1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" in data
        assert "enum_type_obj1" not in data
        assert "enum_type_obj2" not in data

        # enum - not empty
        rv = client.get("/admin/_enumtypefield/?flt0_2=0")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" not in data
        assert "enum_type_obj1" in data
        assert "enum_type_obj2" in data

        # enum - in list
        rv = client.get("/admin/_enumtypefield/?flt0_3=first%2Csecond")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" not in data
        assert "enum_type_obj1" in data
        assert "enum_type_obj2" in data

        # enum - not in list
        rv = client.get("/admin/_enumtypefield/?flt0_4=first%2Csecond")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1_val_1" in data
        assert "enum_type_obj1" not in data
        assert "enum_type_obj2" not in data

        # Test single custom filter on relation
        rv = client.get("/admin/_relation_test/?flt1_0=test1_val_1")
        data = rv.data.decode("utf-8")

        assert "test1_val_1" in data
        assert "test1_val_2" not in data


def test_column_filters_sqla_obj(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(Model1, param, column_filters=[Model1.test1])
        admin.add_view(view)
        assert view._filters
        assert len(view._filters) == 7


def test_column_filters_dotted_path(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)
        fill_db(sqla_db_ext, Model1, Model2)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)

        # FilterInList with a dotted path traverses Model2 -> model1 -> test1.
        flt = filters.FilterInList(column="model1.test1", name="Model1 Test1")
        view = CustomModelView(
            Model2, param, column_filters=[flt], endpoint="_dotted_path"
        )
        admin.add_view(view)

        # Binding resolves the string into the real attribute and records joins.
        assert flt._bound is True
        assert flt.column is Model1.test1
        assert flt._joins  # at least one relationship to traverse
        assert flt.key_name == "model1.test1"
        assert "model1.test1" in view._filter_joins

        client = app.test_client()
        rv = client.get("/admin/_dotted_path/?flt0_0=test1_val_1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2_val_1" in data  # joined row matches
        assert "test2_val_2" not in data  # joined row excluded


def test_column_filters_dotted_path_unresolvable(
    app, sqla_db_ext, admin, session_or_db
):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)
        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)

        flt = filters.FilterInList(column="does_not_exist", name="Missing")
        # Path is unresolved until the view binds it.
        assert flt._bound is False
        assert flt.column == "does_not_exist"

        with pytest.raises(ValueError, match="Could not resolve filter path"):
            CustomModelView(
                Model2, param, column_filters=[flt], endpoint="_dotted_path_bad"
            )


def test_column_filters_dotted_path_relationship_raises(
    app, sqla_db_ext, admin, session_or_db
):
    """A path that resolves to a relationship (not a column) is rejected."""
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)
        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)

        # `model1` on Model2 is the relationship itself, not a column.
        flt = filters.FilterEqual(column="model1", name="Model1")

        with pytest.raises(ValueError, match="Cannot filter on relationship"):
            CustomModelView(
                Model2, param, column_filters=[flt], endpoint="_dotted_path_rel"
            )


def test_column_filters_dotted_path_reuse_same_model(
    app, sqla_db_ext, admin, session_or_db
):
    """A string-path filter can be reused across views over the same model."""
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)
        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)

        flt = filters.FilterInList(column="model1.test1", name="Model1 Test1")

        view1 = CustomModelView(
            Model2, param, column_filters=[flt], endpoint="_reuse_same_1"
        )
        view2 = CustomModelView(
            Model2, param, column_filters=[flt], endpoint="_reuse_same_2"
        )

        # Both views resolved against the same model; rebind is a no-op.
        assert flt._bound_model is Model2
        assert "model1.test1" in view1._filter_joins
        assert "model1.test1" in view2._filter_joins


def test_column_filters_dotted_path_rebind_different_model_raises(
    app, sqla_db_ext, admin, session_or_db
):
    """A string-path filter cannot be reused across views with different models."""
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)
        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)

        flt = filters.FilterInList(column="model1.test1", name="Model1 Test1")
        # First view binds the filter to Model2.
        CustomModelView(Model2, param, column_filters=[flt], endpoint="_rebind_first")
        assert flt._bound_model is Model2

        # Re-using the same filter against a different model raises loudly.
        with pytest.raises(RuntimeError, match="cannot rebind to"):
            CustomModelView(
                Model1,
                param,
                column_filters=[flt],
                endpoint="_rebind_second",
            )


def test_enum_filter_dotted_path(app, sqla_db_ext, admin, session_or_db):
    """Verify _on_column_resolved hook fires for Enum filters with string columns."""
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)
        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)

        flt = filters.EnumEqualFilter(
            column="model1.enum_type_field", name="Model1 Enum"
        )
        # Before binding, enum_class is unset because column was a string.
        assert flt.enum_class is None

        view = CustomModelView(
            Model2, param, column_filters=[flt], endpoint="_dotted_enum"
        )
        admin.add_view(view)

        # Binding ran the hook, which populated enum_class from the resolved column.
        assert flt._bound is True
        assert flt.enum_class is Model1.EnumChoices
        assert flt.column is Model1.enum_type_field


def test_hybrid_property(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class Model1(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model1"
            id = Column(Integer, primary_key=True)
            name = Column(String)
            width = Column(Integer)
            height = Column(Integer)

            @hybrid_property
            def number_of_pixels(self):
                return self.width * self.height

            @hybrid_property
            def number_of_pixels_str(self):
                return str(self.number_of_pixels())

            @number_of_pixels_str.expression  # type: ignore[no-redef]
            def number_of_pixels_str(cls):
                return cast(cls.width * cls.height, String)

        sqla_db_ext.create_all()

        assert tools.is_hybrid_property(Model1, "number_of_pixels")
        assert tools.is_hybrid_property(Model1, "number_of_pixels_str")
        assert not tools.is_hybrid_property(Model1, "height")
        assert not tools.is_hybrid_property(Model1, "width")

        sqla_db_ext.db.session.add(Model1(id=1, name="test_row_1", width=25, height=25))
        sqla_db_ext.db.session.add(Model1(id=2, name="test_row_2", width=10, height=10))
        sqla_db_ext.db.session.commit()

        client = app.test_client()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model1,
            param,
            column_default_sort="number_of_pixels",
            column_filters=[
                filters.IntGreaterFilter(Model1.number_of_pixels, "Number of Pixels")  # type: ignore[arg-type]
            ],
            column_searchable_list=[
                "number_of_pixels_str",
            ],
        )
        admin.add_view(view)

        # filters - hybrid_property integer - greater
        rv = client.get("/admin/model1/?flt0_0=600")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test_row_1" in data
        assert "test_row_2" not in data

        # sorting
        rv = client.get("/admin/model1/?sort=0")
        assert rv.status_code == 200

        _, data = view.get_list(0, None, False, None, None)

        assert len(data) == 2
        assert data[0].name == "test_row_2"  # type: ignore[union-attr]
        assert data[1].name == "test_row_1"  # type: ignore[union-attr]

        # searching
        rv = client.get("/admin/model1/?search=100")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test_row_2" in data
        assert "test_row_1" not in data


def test_hybrid_property_nested(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class Model1(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model1"
            id = Column(Integer, primary_key=True)
            firstname = Column(String)
            lastname = Column(String)

            @hybrid_property
            def fullname(self):
                return f"{self.firstname} {self.lastname}"

        class Model2(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model2"
            id = Column(Integer, primary_key=True)
            name = Column(String)
            owner_id = Column(Integer, ForeignKey("model1.id", ondelete="CASCADE"))
            owner = relationship("Model1", backref=backref("tiles"), uselist=False)

        sqla_db_ext.create_all()

        assert tools.is_hybrid_property(Model2, "owner.fullname")
        assert not tools.is_hybrid_property(Model2, "owner.firstname")

        sqla_db_ext.db.session.add(Model1(id=1, firstname="John", lastname="Dow"))
        sqla_db_ext.db.session.add(Model1(id=2, firstname="Jim", lastname="Smith"))
        sqla_db_ext.db.session.add(Model2(id=1, name="pencil", owner_id=1))
        sqla_db_ext.db.session.add(Model2(id=2, name="key", owner_id=1))
        sqla_db_ext.db.session.add(Model2(id=3, name="map", owner_id=2))
        sqla_db_ext.db.session.commit()

        client = app.test_client()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model2,
            param,
            column_list=("id", "name", "owner.fullname"),
            column_default_sort="id",
        )
        admin.add_view(view)

        rv = client.get("/admin/model2/")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "John Dow" in data
        assert "Jim Smith" in data


def test_url_args(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model1,
            param,
            page_size=2,
            column_searchable_list=["test1"],
            column_filters=["test1"],
        )
        admin.add_view(view)

        sqla_db_ext.db.session.add(Model1("data1"))
        sqla_db_ext.db.session.add(Model1("data2"))
        sqla_db_ext.db.session.add(Model1("data3"))
        sqla_db_ext.db.session.add(Model1("data4"))
        sqla_db_ext.db.session.commit()

        client = app.test_client()

        rv = client.get("/admin/model1/")
        data = rv.data.decode("utf-8")
        assert "data1" in data
        assert "data3" not in data

        # page
        rv = client.get("/admin/model1/?page=1")
        data = rv.data.decode("utf-8")
        assert "data1" not in data
        assert "data3" in data

        # sort
        rv = client.get("/admin/model1/?sort=0&desc=1")
        data = rv.data.decode("utf-8")
        assert "data1" not in data
        assert "data3" in data
        assert "data4" in data

        # search
        rv = client.get("/admin/model1/?search=data1")
        data = rv.data.decode("utf-8")
        assert "data1" in data
        assert "data2" not in data

        rv = client.get("/admin/model1/?search=^data1")
        data = rv.data.decode("utf-8")
        assert "data2" not in data

        # like
        rv = client.get("/admin/model1/?flt0=0&flt0v=data1")
        data = rv.data.decode("utf-8")
        assert "data1" in data

        # not like
        rv = client.get("/admin/model1/?flt0=1&flt0v=data1")
        data = rv.data.decode("utf-8")
        assert "data2" in data


def test_non_int_pk(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class Model(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model"
            id = Column(String, primary_key=True)
            test = Column(String)

        sqla_db_ext.create_all()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(Model, param, form_columns=["id", "test"])
        admin.add_view(view)

        client = app.test_client()

        rv = client.get("/admin/model/")
        assert rv.status_code == 200

        rv = client.post("/admin/model/new/", data=dict(id="test1", test="test2"))
        assert rv.status_code == 302

        rv = client.get("/admin/model/")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test1" in data

        rv = client.get("/admin/model/edit/?id=test1")
        assert rv.status_code == 200
        data = rv.data.decode("utf-8")
        assert "test2" in data


def test_form_columns(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class Model(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model"
            id = Column(String, primary_key=True)
            int_field = Column(Integer)
            datetime_field = Column(DateTime)
            text_field = Column(Text)
            excluded_column = Column(String)

        class ChildModel(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "child_model"

            class EnumChoices(enum.Enum):
                first = 1
                second = 2

            id = Column(String, primary_key=True)
            model_id = Column(Integer, ForeignKey(Model.id))
            model = relationship(Model, backref="backref")
            enum_field = Column(Enum("model1_v1", "model1_v2"), nullable=True)  # type: ignore[var-annotated]
            choice_field = Column(String, nullable=True)
            sqla_utils_choice = Column(
                ChoiceType(
                    [("choice-1", "First choice"), ("choice-2", "Second choice")]
                )
            )
            sqla_utils_enum = Column(ChoiceType(EnumChoices, impl=Integer()))

        sqla_db_ext.create_all()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view1 = CustomModelView(
            Model,
            param,
            endpoint="view1",
            form_columns=("int_field", "text_field"),
        )
        view2 = CustomModelView(
            Model,
            param,
            endpoint="view2",
            form_excluded_columns=("excluded_column",),
        )
        view3 = CustomModelView(ChildModel, param, endpoint="view3")

        form1 = view1.create_form()
        form2 = view2.create_form()
        form3 = view3.create_form()

        assert "int_field" in form1._fields
        assert "text_field" in form1._fields
        assert "datetime_field" not in form1._fields
        assert "excluded_column" not in form2._fields

        # check that relation shows up as a query select
        assert type(form3.model).__name__ == "QuerySelectField"  # type: ignore[attr-defined]

        # check that select field is rendered if form_choices were specified
        assert type(form3.choice_field).__name__ == "Select2Field"  # type: ignore[attr-defined]

        # check that select field is rendered for enum fields
        assert type(form3.enum_field).__name__ == "Select2Field"  # type: ignore[attr-defined]

        # check that sqlalchemy_utils field types are handled appropriately
        assert type(form3.sqla_utils_choice).__name__ == "Select2Field"  # type: ignore[attr-defined]
        assert type(form3.sqla_utils_enum).__name__ == "Select2Field"  # type: ignore[attr-defined]

        # test form_columns with model objects
        view4 = CustomModelView(
            Model, param, endpoint="view1", form_columns=[Model.int_field]
        )
        form4 = view4.create_form()
        assert "int_field" in form4._fields


@pytest.mark.xfail(raises=Exception)
def test_complex_form_columns(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        M1, M2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        # test using a form column in another table
        view = CustomModelView(M2, param, form_columns=["model1.test1"])
        view.create_form()


def test_form_args(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class Model(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model"
            id = Column(String, primary_key=True)
            test = Column(String, nullable=False)

        sqla_db_ext.create_all()

        shared_form_args = {"test": {"validators": [validators.Regexp("test")]}}

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(Model, param, form_args=shared_form_args)
        admin.add_view(view)

        create_form = view.create_form()
        assert len(create_form.test.validators) == 2  # type: ignore[attr-defined]

        # ensure shared field_args don't create duplicate validators
        edit_form = view.edit_form()
        assert len(edit_form.test.validators) == 2  # type: ignore[attr-defined]


def test_form_override(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class Model(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model"
            id = Column(String, primary_key=True)
            test = Column(String)

        sqla_db_ext.create_all()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view1 = CustomModelView(Model, param, endpoint="view1")
        view2 = CustomModelView(
            Model,
            param,
            endpoint="view2",
            form_overrides=dict(test=fields.FileField),
        )
        admin.add_view(view1)
        admin.add_view(view2)

        assert view1._create_form_class.test.field_class == fields.StringField  # type: ignore[attr-defined]
        assert view2._create_form_class.test.field_class == fields.FileField  # type: ignore[attr-defined]


def test_form_onetoone(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class Model1(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model1"
            id = Column(Integer, primary_key=True)
            test = Column(String)

        class Model2(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model2"
            id = Column(Integer, primary_key=True)

            model1_id = Column(Integer, ForeignKey(Model1.id))
            model1 = relationship(Model1, backref=backref("model2", uselist=False))

        sqla_db_ext.create_all()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view1 = CustomModelView(Model1, param, endpoint="view1")
        view2 = CustomModelView(Model2, param, endpoint="view2")
        admin.add_view(view1)
        admin.add_view(view2)

        model1 = Model1(test="test")
        model2 = Model2(model1=model1)
        sqla_db_ext.db.session.add(model1)
        sqla_db_ext.db.session.add(model2)
        sqla_db_ext.db.session.commit()

        assert model1.model2 == model2
        assert model2.model1 == model1

        assert not view1._create_form_class.model2.field_class.widget.multiple  # type: ignore[attr-defined]
        assert not view2._create_form_class.model1.field_class.widget.multiple  # type: ignore[attr-defined]


def test_relations():
    # TODO: test relations
    pass


def test_on_model_change_delete(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, _ = create_models(sqla_db_ext)

        class ModelView(CustomModelView):
            def on_model_change(self, form, model, is_created):
                model.test1 = model.test1.upper()

            def on_model_delete(self, model):
                self.deleted = True

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = ModelView(Model1, param)
        admin.add_view(view)

        client = app.test_client()

        client.post("/admin/model1/new/", data=dict(test1="test1large", test2="test2"))

        model = sqla_db_ext.db.session.query(Model1).first()
        assert model.test1 == "TEST1LARGE"

        url = f"/admin/model1/edit/?id={model.id}"
        client.post(url, data=dict(test1="test1small", test2="test2large"))

        model = sqla_db_ext.db.session.query(Model1).first()
        assert model.test1 == "TEST1SMALL"

        url = f"/admin/model1/delete/?id={model.id}"
        client.post(url)
        assert view.deleted


def test_multiple_delete(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        M1, _ = create_models(sqla_db_ext)

        sqla_db_ext.db.session.add_all([M1("a"), M1("b"), M1("c")])
        sqla_db_ext.db.session.commit()
        assert sqla_db_ext.db.session.query(M1).count() == 3

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = ModelView(M1, param)
        admin.add_view(view)

        client = app.test_client()

        rv = client.post(
            "/admin/model1/action/", data=dict(action="delete", rowid=[1, 2, 3])
        )
        assert rv.status_code == 302
        assert sqla_db_ext.db.session.query(M1).count() == 0


def test_default_sort(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        M1, _ = create_models(sqla_db_ext)

        sqla_db_ext.db.session.add_all([M1("c", "x"), M1("b", "x"), M1("a", "y")])
        sqla_db_ext.db.session.commit()
        assert sqla_db_ext.db.session.query(M1).count() == 3

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(M1, param, column_default_sort="test1")
        admin.add_view(view)

        _, data = view.get_list(0, None, False, None, None)
        assert len(data) == 3
        assert data[0].test1 == "a"  # type: ignore[union-attr]
        assert data[1].test1 == "b"  # type: ignore[union-attr]
        assert data[2].test1 == "c"  # type: ignore[union-attr]

        # test default sort on renamed columns - with column_list scaffolding
        view2 = CustomModelView(
            M1,
            param,
            column_default_sort="test1",
            column_labels={"test1": "blah"},
            endpoint="m1_2",
        )
        admin.add_view(view2)

        _, data = view2.get_list(0, None, False, None, None)

        assert len(data) == 3
        assert data[0].test1 == "a"  # type: ignore[union-attr]
        assert data[1].test1 == "b"  # type: ignore[union-attr]
        assert data[2].test1 == "c"  # type: ignore[union-attr]

        # test default sort on renamed columns - without column_list scaffolding
        view3 = CustomModelView(
            M1,
            param,
            column_default_sort="test1",
            column_labels={"test1": "blah"},
            endpoint="m1_3",
            column_list=["test1"],
        )
        admin.add_view(view3)

        _, data = view3.get_list(0, None, False, None, None)

        assert len(data) == 3
        assert data[0].test1 == "a"  # type: ignore[union-attr]
        assert data[1].test1 == "b"  # type: ignore[union-attr]
        assert data[2].test1 == "c"  # type: ignore[union-attr]

        # test default sort with multiple columns
        order = [("test2", False), ("test1", False)]
        view4 = CustomModelView(M1, param, column_default_sort=order, endpoint="m1_4")
        admin.add_view(view4)

        _, data = view4.get_list(0, None, False, None, None)

        assert len(data) == 3
        assert data[0].test1 == "b"  # type: ignore[union-attr]
        assert data[1].test1 == "c"  # type: ignore[union-attr]
        assert data[2].test1 == "a"  # type: ignore[union-attr]


def test_complex_sort(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        M1, M2 = create_models(sqla_db_ext)

        m1 = M1(test1="c", test2="x")
        sqla_db_ext.db.session.add(m1)
        sqla_db_ext.db.session.add(M2("c", model1=m1))

        m2 = M1(test1="b", test2="x")
        sqla_db_ext.db.session.add(m2)
        sqla_db_ext.db.session.add(M2("b", model1=m2))

        m3 = M1(test1="a", test2="y")
        sqla_db_ext.db.session.add(m3)
        sqla_db_ext.db.session.add(M2("a", model1=m3))

        sqla_db_ext.db.session.commit()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        # test sorting on relation string - 'model1.test1'
        view = CustomModelView(
            M2,
            param,
            column_list=["string_field", "model1.test1"],
            column_sortable_list=["model1.test1"],
        )
        admin.add_view(view)
        view2 = CustomModelView(
            M2,
            param,
            column_list=["string_field", "model1"],
            column_sortable_list=[("model1", ("model1.test2", "model1.test1"))],
            endpoint="m1_2",
        )
        admin.add_view(view2)

        client = app.test_client()

        rv = client.get("/admin/model2/?sort=0")
        assert rv.status_code == 200

        _, data = view.get_list(0, "model1.test1", False, None, None)

        assert data[0].model1.test1 == "a"  # type: ignore[union-attr]
        assert data[1].model1.test1 == "b"  # type: ignore[union-attr]
        assert data[2].model1.test1 == "c"  # type: ignore[union-attr]

        # test sorting on multiple columns in related model
        rv = client.get("/admin/m1_2/?sort=0")
        assert rv.status_code == 200

        _, data = view2.get_list(0, "model1", False, None, None)

        assert data[0].model1.test1 == "b"  # type: ignore[union-attr]
        assert data[1].model1.test1 == "c"  # type: ignore[union-attr]
        assert data[1].model1.test1 == "c"  # type: ignore[union-attr]
        assert data[2].model1.test1 == "a"  # type: ignore[union-attr]


@pytest.mark.xfail(raises=Exception)
def test_complex_sort_exception(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        M1, M2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        # test column_sortable_list on a related table's column object
        view = CustomModelView(
            M2, param, endpoint="model2_3", column_sortable_list=[M1.test1]
        )
        admin.add_view(view)

        sort_column = view._get_column_by_idx(0)[0]  # type: ignore[index]
        _, data = view.get_list(0, sort_column, False, None, None)

        assert len(data) == 2
        print(type(data[0]))
        print(type(data[0].model1))  # type: ignore[union-attr]
        assert data[0].model1.test1 == "a"  # type: ignore[union-attr]
        assert data[1].model1.test1 == "b"  # type: ignore[union-attr]


def test_default_complex_sort(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        M1, M2 = create_models(sqla_db_ext)

        m1 = M1("b")
        sqla_db_ext.db.session.add(m1)
        sqla_db_ext.db.session.add(M2("c", model1=m1))

        m2 = M1("a")
        sqla_db_ext.db.session.add(m2)
        sqla_db_ext.db.session.add(M2("c", model1=m2))

        sqla_db_ext.db.session.commit()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(M2, param, column_default_sort="model1.test1")
        admin.add_view(view)

        _, data = view.get_list(0, None, False, None, None)

        assert len(data) == 2
        assert data[0].model1.test1 == "a"  # type: ignore[union-attr]
        assert data[1].model1.test1 == "b"  # type: ignore[union-attr]

        # test column_default_sort on a related table's column object
        view2 = CustomModelView(
            M2, param, endpoint="model2_2", column_default_sort=(M1.test1, False)
        )
        admin.add_view(view2)

        _, data = view2.get_list(0, None, False, None, None)

        assert len(data) == 2
        assert data[0].model1.test1 == "a"  # type: ignore[union-attr]
        assert data[1].model1.test1 == "b"  # type: ignore[union-attr]


@pytest.mark.filterwarnings(
    "ignore:'iter_groups' is expected to return 4 items tuple since wtforms 3.1, this "
    "will be mandatory in wtforms 3.2:DeprecationWarning",
)
def test_extra_fields(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, _ = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model1,
            param,
            form_extra_fields={"extra_field": fields.StringField("Extra Field")},
        )
        admin.add_view(view)

        client = app.test_client()

        rv = client.get("/admin/model1/new/")
        assert rv.status_code == 200

        # Check presence and order
        data = rv.data.decode("utf-8")
        assert "Extra Field" in data
        pos1 = data.find("Extra Field")
        pos2 = data.find("Test1")
        assert pos2 < pos1


def test_extra_field_order(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, _ = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model1,
            param,
            form_columns=("extra_field", "test1"),
            form_extra_fields={"extra_field": fields.StringField("Extra Field")},
        )
        admin.add_view(view)

        client = app.test_client()

        rv = client.get("/admin/model1/new/")
        assert rv.status_code == 200

        # Check presence and order
        data = rv.data.decode("utf-8")
        pos1 = data.find("Extra Field")
        pos2 = data.find("Test1")
        assert pos2 > pos1


def _make_params():
    """
    Return 12 test cases: one testing the deprecated session API with English, and the
    rest testing the new db API with all locales.
    """
    locales = [
        ("en", "Home"),
        ("cs", "Domů"),
        ("de", "Start"),
        ("es", "Inicio"),
        ("fa", "خانه"),
        ("fr", "Accueil"),
        ("pt", "Início"),
        ("ru", "Главная"),
        ("pa", "ਹੋਮ"),
        ("zh_CN", "首页"),
        ("zh_TW", "首頁"),
    ]

    # Test deprecated session API once with English
    params = [pytest.param("session", "en", "Home", id="session-en")]

    # Test new db API with all locales
    params.extend(
        [
            pytest.param("sqla_db_ext", locale, text, id=f"sqla_db_ext-{locale}")
            for locale, text in locales
        ]
    )

    return params


@pytest.mark.parametrize("session_or_db, locale, expect_text", _make_params())
@pytest.mark.filterwarnings(
    "ignore:'iter_groups' is expected to return 4 items tuple since wtforms 3.1, this "
    "will be mandatory in wtforms 3.2:DeprecationWarning",
)
@flask_babel_test_decorator
def test_modelview_localization(
    request, app, locale, expect_text, session_or_db, sqla_db_ext
):
    # We need to configure the default Babel locale _before_ the `babel` fixture is
    # initialised, so we have to use `request.getfixturevalue` to pull the fixture
    # within the test function rather than the test signature. The `admin` fixture
    # pulls in the `babel` fixture, which will then use the configuration here.
    app.config["BABEL_DEFAULT_LOCALE"] = locale
    admin = request.getfixturevalue("admin")

    with app.app_context():
        Model1, _ = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model1,
            param,
            column_filters=[
                "test1",
                "bool_field",
                "date_field",
                "datetime_field",
                "time_field",
            ],
        )

        admin.add_view(view)

        client = app.test_client()

        rv = client.get("/admin/model1/")
        assert expect_text in rv.text
        assert rv.status_code == 200

        rv = client.get("/admin/model1/new/")
        assert rv.status_code == 200


@flask_babel_test_decorator
def test_modelview_named_filter_localization(request, app, session_or_db, sqla_db_ext):
    # We need to configure the default Babel locale _before_ the `babel` fixture is
    # initialised, so we have to use `request.getfixturevalue` to pull the fixture
    # within the test function rather than the test signature. The `admin` fixture
    # pulls in the `babel` fixture, which will then use the configuration here.
    app.config["BABEL_DEFAULT_LOCALE"] = "de"
    _ = request.getfixturevalue("admin")

    with app.app_context():
        Model1, _ = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model1,
            param,
            named_filter_urls=True,
            column_filters=["test1"],
        )
        filters = view.get_filters()
        assert filters
        flt = filters[2]
        with app.test_request_context():
            flt_name = view.get_filter_arg(2, flt)
        assert "test1_equals" == flt_name


def test_custom_form_base(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class TestForm(form.BaseForm):
            pass

        Model1, _ = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(Model1, param, form_base_class=TestForm)
        admin.add_view(view)

        assert hasattr(view._create_form_class, "test1")

        create_form = view.create_form()
        assert isinstance(create_form, TestForm)


def test_ajax_fk(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model2,
            param,
            url="view",
            form_ajax_refs={"model1": {"fields": ("test1", "test2")}},
        )
        admin.add_view(view)

        assert "model1" in view._form_ajax_refs

        model = Model1("first")
        model2 = Model1("foo", "bar")
        sqla_db_ext.db.session.add_all([model, model2])
        sqla_db_ext.db.session.commit()

        # Check loader
        loader = view._form_ajax_refs["model1"]
        mdl = loader.get_one(model.id)
        assert mdl.test1 == model.test1

        items = loader.get_list("fir")
        assert len(items) == 1
        assert items[0].id == model.id  # type: ignore[union-attr]

        items = loader.get_list("bar")
        assert len(items) == 1
        assert items[0].test1 == "foo"  # type: ignore[union-attr]

        # Check form generation
        form = view.create_form()
        assert form.model1.__class__.__name__ == "AjaxSelectField"  # type: ignore[attr-defined]

        with app.test_request_context("/admin/view/"):
            assert 'value=""' not in form.model1()  # type: ignore[attr-defined]

            form.model1.data = model  # type: ignore[attr-defined]
            assert (
                f'data-json="[{model.id}, &quot;first&quot;]"' in form.model1()  # type: ignore[attr-defined]
                or f'data-json="[{model.id}, &#34;first&#34;]"' in form.model1()  # type: ignore[attr-defined]
            )
            assert 'value="1"' in form.model1()  # type: ignore[attr-defined]

        # Check querying
        client = app.test_client()

        req = client.get("/admin/view/ajax/lookup/?name=model1&query=foo")
        assert req.data.decode("utf-8") == f'[[{model2.id}, "foo"]]'

        # Check submitting
        req = client.post("/admin/view/new/", data={"model1": as_unicode(model.id)})
        mdl = sqla_db_ext.db.session.query(Model2).first()

        assert mdl is not None
        assert mdl.model1 is not None
        assert mdl.model1.id == model.id
        assert mdl.model1.test1 == "first"


def test_ajax_fk_multi(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class Model1(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model1"

            id = Column(Integer, primary_key=True)
            name = Column(String(20))

            def __str__(self):
                return self.name

        table = Table(
            "m2m",
            sqla_db_ext.Base.metadata,
            Column("model1_id", Integer, ForeignKey("model1.id")),
            Column("model2_id", Integer, ForeignKey("model2.id")),
        )

        class Model2(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model2"

            id = Column(Integer, primary_key=True)
            name = Column(String(20))

            model1_id = Column(Integer(), ForeignKey(Model1.id))
            model1 = relationship(Model1, backref="models2", secondary=table)

        sqla_db_ext.create_all()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(
            Model2,
            param,
            url="view",
            form_ajax_refs={"model1": {"fields": ["name"]}},
        )
        admin.add_view(view)

        assert "model1" in view._form_ajax_refs

        model = Model1(name="first")
        sqla_db_ext.db.session.add_all([model, Model1(name="foo")])
        sqla_db_ext.db.session.commit()

        # Check form generation
        form = view.create_form()
        assert form.model1.__class__.__name__ == "AjaxSelectMultipleField"  # type: ignore[attr-defined]

        with app.test_request_context("/admin/view/"):
            assert 'data-json="[]"' in form.model1()  # type: ignore[attr-defined]

            form.model1.data = [model]  # type: ignore[attr-defined]
            assert (
                'data-json="[[1, &quot;first&quot;]]"' in form.model1()  # type: ignore[attr-defined]
                or 'data-json="[[1, &#34;first&#34;]]"' in form.model1()  # type: ignore[attr-defined]
            )

        # Check submitting
        client = app.test_client()
        client.post("/admin/view/new/", data={"model1": as_unicode(model.id)})
        mdl = sqla_db_ext.db.session.query(Model2).first()

        assert mdl is not None
        assert mdl.model1 is not None
        assert len(mdl.model1) == 1


def test_safe_redirect(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, _ = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(Model1, param)
        admin.add_view(view)

        client = app.test_client()

        rv = client.post(
            "/admin/model1/new/?url=http://localhost/admin/model2view/",
            data=dict(
                test1="test1large",
                test2="test2",
                _continue_editing="Save and Continue Editing",
            ),
        )

        assert rv.status_code == 302

        # werkzeug 2.1.0+ now returns *relative* redirect/location by default.
        expected = "/admin/model1/edit/"

        # handle old werkzeug (or if relative location is disabled via
        # `autocorrect_location_header=True`)
        if (
            not hasattr(rv, "autocorrect_location_header")
            or rv.autocorrect_location_header
        ):
            expected = "http://localhost" + expected

        assert rv.location.startswith(expected)
        assert "url=http://localhost/admin/model2view/" in rv.location
        assert "id=1" in rv.location

        rv = client.post(
            "/admin/model1/new/?url=http://google.com/evil/",
            data=dict(
                test1="test1large",
                test2="test2",
                _continue_editing="Save and Continue Editing",
            ),
        )

        assert rv.status_code == 302
        assert rv.location.startswith(expected)
        assert "url=/admin/model1/" in rv.location
        assert "id=2" in rv.location


def test_relative_redirect_on_save_and_add_another(
    app, sqla_db_ext, admin, session_or_db
):
    """
    Test that redirect URL after "Save and Add Another" is relative
    """
    with app.app_context():
        Model1, _ = create_models(sqla_db_ext)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(Model1, param)
        admin.add_view(view)

        client = app.test_client()

        rv = client.post(
            "/admin/model1/new/?url=http://localhost/admin/model2view/",
            data=dict(
                test1="test",
                _add_another="Save and Add Another",
            ),
        )

        assert rv.status_code == 302

        # werkzeug 2.1.0+ now returns *relative* redirect/location by default.
        expected = "/admin/model1/new/"

        # handle old werkzeug (or if relative location is disabled via
        # `autocorrect_location_header=True`)
        if (
            not hasattr(rv, "autocorrect_location_header")
            or rv.autocorrect_location_header
        ):
            expected = "http://localhost" + expected

        assert rv.location.startswith(expected)
        assert "url=http://localhost/admin/model2view/" in rv.location


def test_simple_list_pager(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, _ = create_models(sqla_db_ext)

        class TestModelView(CustomModelView):
            simple_list_pager = True

            def get_count_query(self):
                raise AssertionError()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = TestModelView(Model1, param)
        admin.add_view(view)

        count, data = view.get_list(0, None, False, None, None)
        assert count is None


def test_customising_page_size(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        M1, _ = create_models(sqla_db_ext)

        sqla_db_ext.db.session.add_all(
            [M1(str(f"instance-{x+1:03d}")) for x in range(101)]
        )

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view1 = CustomModelView(
            M1, param, endpoint="view1", page_size=20, can_set_page_size=False
        )
        admin.add_view(view1)

        view2 = CustomModelView(
            M1, param, endpoint="view2", page_size=5, can_set_page_size=False
        )
        admin.add_view(view2)

        view3 = CustomModelView(
            M1, param, endpoint="view3", page_size=20, can_set_page_size=True
        )
        admin.add_view(view3)

        view4 = CustomModelView(
            M1,
            param,
            endpoint="view4",
            page_size=5,
            page_size_options=(5, 10, 15),
            can_set_page_size=True,
        )
        admin.add_view(view4)

        client = app.test_client()

        rv = client.get("/admin/view1/")
        assert "instance-020" in rv.text
        assert "instance-021" not in rv.text

        # `can_set_page_size=False`, so only the default of 20 is available.
        rv = client.get("/admin/view1/?page_size=50")
        assert "instance-020" in rv.text
        assert "instance-021" not in rv.text

        # Check view2, which has `page_size=5` to change the default page size
        rv = client.get("/admin/view2/")
        assert "instance-005" in rv.text
        assert "instance-006" not in rv.text

        # Check view3, which has `can_set_page_size=True`
        rv = client.get("/admin/view3/")
        assert "instance-020" in rv.text
        assert "instance-021" not in rv.text

        rv = client.get("/admin/view3/?page_size=50")
        assert "instance-050" in rv.text
        assert "instance-051" not in rv.text

        rv = client.get("/admin/view3/?page_size=100")
        assert "instance-100" in rv.text
        assert "instance-101" not in rv.text

        # Invalid page sizes are reset to the default
        rv = client.get("/admin/view3/?page_size=1")
        assert "instance-020" in rv.text
        assert "instance-021" not in rv.text

        # Check view4, which has custom `page_size_options`
        rv = client.get("/admin/view4/")
        assert "instance-005" in rv.text
        assert "instance-006" not in rv.text

        # Invalid page sizes are reset to the default
        rv = client.get("/admin/view4/?page_size=1")
        assert "instance-005" in rv.text
        assert "instance-006" not in rv.text

        rv = client.get("/admin/view4/?page_size=10")
        assert "instance-010" in rv.text
        assert "instance-011" not in rv.text

        rv = client.get("/admin/view4/?page_size=15")
        assert "instance-015" in rv.text
        assert "instance-016" not in rv.text


def test_unlimited_page_size(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        M1, _ = create_models(sqla_db_ext)

        sqla_db_ext.db.session.add_all(
            [
                M1("1"),
                M1("2"),
                M1("3"),
                M1("4"),
                M1("5"),
                M1("6"),
                M1("7"),
                M1("8"),
                M1("9"),
                M1("10"),
                M1("11"),
                M1("12"),
                M1("13"),
                M1("14"),
                M1("15"),
                M1("16"),
                M1("17"),
                M1("18"),
                M1("19"),
                M1("20"),
                M1("21"),
            ]
        )

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(M1, param)

        # test 0 as page_size
        _, data = view.get_list(0, None, False, None, None, execute=True, page_size=0)
        assert len(data) == 21

        # test False as page_size
        _, data = view.get_list(
            0, None, False, None, None, execute=True, page_size=False
        )
        assert len(data) == 21


def test_advanced_joins(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class Model1(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model1"
            id = Column(Integer, primary_key=True)
            val1 = Column(String(20))
            test = Column(String(20))

        class Model2(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model2"
            id = Column(Integer, primary_key=True)
            val2 = Column(String(20))

            model1_id = Column(Integer, ForeignKey(Model1.id))
            model1 = relationship(Model1, backref="model2")

        class Model3(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model3"
            id = Column(Integer, primary_key=True)
            val2 = Column(String(20))

            model2_id = Column(Integer, ForeignKey(Model2.id))
            model2 = relationship(Model2, backref="model3")

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view1 = CustomModelView(Model1, param)
        admin.add_view(view1)

        view2 = CustomModelView(Model2, param)
        admin.add_view(view2)

        view3 = CustomModelView(Model3, param)
        admin.add_view(view3)

        # Test joins
        attr, path = tools.get_field_with_path(Model2, "model1.val1")
        assert attr == Model1.val1
        assert path == [Model2.model1]

        attr, path = tools.get_field_with_path(Model1, "model2.val2")
        assert attr == Model2.val2
        assert id(path[0]) == id(Model1.model2)

        attr, path = tools.get_field_with_path(Model3, "model2.model1.val1")
        assert attr == Model1.val1
        assert path == [Model3.model2, Model2.model1]

        # Test how joins are applied
        query = view3.get_query()

        joins: dict[tuple[bool, Any], Any] = {}
        q1, joins, alias = view3._apply_path_joins(query, joins, path)
        assert (True, Model3.model2) in joins
        assert (True, Model2.model1) in joins
        assert alias is not None

        # Check if another join would use same path
        attr, path = tools.get_field_with_path(Model2, "model1.test")
        q2, joins, alias = view2._apply_path_joins(query, joins, path)

        assert len(joins) == 2

        if hasattr(q2, "_join_entities"):
            for p in q2._join_entities:
                assert p in q1._join_entities  # type: ignore[attr-defined]

        assert alias is not None

        # Check if normal properties are supported by tools.get_field_with_path
        attr, path = tools.get_field_with_path(Model2, Model1.test)
        assert attr == Model1.test
        assert path == [Model1.__table__]

        q3, joins, alias = view2._apply_path_joins(view2.get_query(), joins, path)
        assert len(joins) == 3
        assert alias is None


def test_multipath_joins(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class Model1(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model1"
            id = Column(Integer, primary_key=True)
            val1 = Column(String(20))
            test = Column(String(20))

        class Model2(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "model2"
            id = Column(Integer, primary_key=True)
            val2 = Column(String(20))

            first_id = Column(Integer, ForeignKey(Model1.id))
            first = relationship(Model1, backref="first", foreign_keys=[first_id])

            second_id = Column(Integer, ForeignKey(Model1.id))
            second = relationship(Model1, backref="second", foreign_keys=[second_id])

        sqla_db_ext.create_all()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(Model2, param, filters=["first.test"])
        admin.add_view(view)

        client = app.test_client()

        rv = client.get("/admin/model2/")
        assert rv.status_code == 200


def test_different_bind_joins(
    babel, app_with_binds, session_or_db, sqla_db_ext_with_binds
):
    admin = Admin(app_with_binds)

    class Model1(sqla_db_ext_with_binds.Base):  # type: ignore[name-defined, misc]
        __tablename__ = "model1"
        id = Column(Integer, primary_key=True)
        val1 = Column(String(20))

    class Model2(sqla_db_ext_with_binds.Base):  # type: ignore[name-defined, misc]
        __tablename__ = "model2"
        __bind_key__ = "other"
        id = Column(Integer, primary_key=True)
        val1 = Column(String(20))
        first_id = Column(Integer, ForeignKey(Model1.id))
        first = relationship(Model1)

    sqla_db_ext_with_binds.create_all()

    param = skip_or_return_session_or_db(sqla_db_ext_with_binds, session_or_db)

    view = CustomModelView(Model2, param)
    admin.add_view(view)

    client = app_with_binds.test_client()

    rv = client.get("/admin/model2/")
    assert rv.status_code == 200


def test_model_default(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        _, Model2 = create_models(sqla_db_ext)

        class ModelView(CustomModelView):
            pass

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = ModelView(Model2, param)
        admin.add_view(view)

        client = app.test_client()
        rv = client.post("/admin/model2/new/", data=dict())
        assert b"This field is required" not in rv.data


def test_export_csv(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)

        for _x in range(5):
            fill_db(sqla_db_ext, Model1, Model2)

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view1 = CustomModelView(
            Model1,
            param,
            can_export=True,
            column_list=["test1", "test2"],
            export_max_rows=2,
            endpoint="row_limit_2",
        )
        admin.add_view(view1)
        view2 = CustomModelView(
            Model1,
            param,
            can_export=True,
            column_list=["test1", "test2"],
            endpoint="no_row_limit",
        )
        admin.add_view(view2)

        client = app.test_client()

        # test export_max_rows
        rv = client.get("/admin/row_limit_2/export/csv/")
        data = rv.data.decode("utf-8")
        assert rv.status_code == 200
        assert (
            "Test1,Test2\r\n"
            + "test1_val_1,test2_val_1\r\n"
            + "test1_val_2,test2_val_2\r\n"
            == data
        )

        # test row limit without export_max_rows
        rv = client.get("/admin/no_row_limit/export/csv/")
        data = rv.data.decode("utf-8")
        assert rv.status_code == 200
        assert len(data.splitlines()) > 21


STRING_CONSTANT = "Anyway, here's Wonderwall"


def test_string_null_behavior(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class StringTestModel(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "string_test_model"
            id = Column(Integer, primary_key=True)
            test_no = Column(Integer, nullable=False)
            string_field = Column(String)
            string_field_nonull = Column(String, nullable=False)
            string_field_nonull_default = Column(String, nullable=False, default="")
            text_field = Column(Text)
            text_field_nonull = Column(Text, nullable=False)
            text_field_nonull_default = Column(Text, nullable=False, default="")

        sqla_db_ext.create_all()

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        view = CustomModelView(StringTestModel, param)
        admin.add_view(view)

        client = app.test_client()

        valid_params = {
            "test_no": 1,
            "string_field_nonull": STRING_CONSTANT,
            "text_field_nonull": STRING_CONSTANT,
        }
        rv = client.post("/admin/stringtestmodel/new/", data=valid_params)
        assert rv.status_code == 302

        # Assert on defaults
        valid_inst = (
            sqla_db_ext.db.session.query(StringTestModel)
            .filter(StringTestModel.test_no == 1)
            .one()
        )
        assert valid_inst.string_field is None
        assert valid_inst.string_field_nonull == STRING_CONSTANT
        assert valid_inst.string_field_nonull_default == ""
        assert valid_inst.text_field is None
        assert valid_inst.text_field_nonull == STRING_CONSTANT
        assert valid_inst.text_field_nonull_default == ""

        # Assert that nulls are caught on the non-null fields
        invalid_string_field = {
            "test_no": 2,
            "string_field_nonull": None,
            "text_field_nonull": STRING_CONSTANT,
        }
        rv = client.post("/admin/stringtestmodel/new/", data=invalid_string_field)
        assert rv.status_code == 200
        assert b"This field is required." in rv.data
        assert (
            sqla_db_ext.db.session.query(StringTestModel)
            .filter(StringTestModel.test_no == 2)
            .all()
            == []
        )

        invalid_text_field = {
            "test_no": 3,
            "string_field_nonull": STRING_CONSTANT,
            "text_field_nonull": None,
        }
        rv = client.post("/admin/stringtestmodel/new/", data=invalid_text_field)
        assert rv.status_code == 200
        assert b"This field is required." in rv.data
        assert (
            sqla_db_ext.db.session.query(StringTestModel)
            .filter(StringTestModel.test_no == 3)
            .all()
            == []
        )

        # Assert that empty strings are converted to None on nullable fields.
        empty_strings = {
            "test_no": 4,
            "string_field": "",
            "text_field": "",
            "string_field_nonull": STRING_CONSTANT,
            "text_field_nonull": STRING_CONSTANT,
        }
        rv = client.post("/admin/stringtestmodel/new/", data=empty_strings)
        assert rv.status_code == 302
        empty_string_inst = (
            sqla_db_ext.db.session.query(StringTestModel)
            .filter(StringTestModel.test_no == 4)
            .one()
        )
        assert empty_string_inst.string_field is None
        assert empty_string_inst.text_field is None


def test_form_overrides(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():

        class UserModel(sqla_db_ext.Base):  # type: ignore[name-defined, misc]
            __tablename__ = "user_model"
            id = Column(Integer, primary_key=True)
            text = Column(String)

    class UserView(ModelView):
        form_overrides = {
            "text": PasswordField,
        }

    param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
    admin.add_view(UserView(UserModel, param))
    client = app.test_client()
    # Test that the create form uses <input type="password">
    rv = client.get("/admin/usermodel/new/")
    data = rv.data.decode("utf-8")
    with open("f.html", "w") as f:
        f.write(data)
    # Check that the password input is of type password
    assert (
        '<input class="form-control" id="text" name="text" type="password" value="">'
        in data
    )


def match_page_title_and_icon(data: str, title: str, icon_html: str) -> bool:
    # Regex to match the given HTML block with possible whitespace/newlines
    pattern = re.compile(rf'<h4 class="mb-4">.*?{re.escape(title)}.*?</h4>', re.DOTALL)
    title_match = pattern.search(data)

    pattern = re.compile(
        rf'<h4 class="mb-4">.*?{re.escape(icon_html)}.*?</h4>', re.DOTALL
    )
    icon_match = pattern.search(data)

    return bool(title_match and icon_match)


def test_page_title(app, sqla_db_ext, admin, session_or_db):
    with app.app_context():
        Model1, Model2 = create_models(sqla_db_ext)
        sqla_db_ext.db.session.add_all(
            [
                Model1("1"),
                Model1("2"),
            ]
        )
        sqla_db_ext.db.session.commit()

        class MyModelView(CustomModelView):
            can_view_details = True

        param = skip_or_return_session_or_db(sqla_db_ext, session_or_db)
        # test column_list with a list of strings
        view = MyModelView(
            Model1,
            param,
            name="My Model1",
            menu_icon_type="fa",
            menu_icon_value="fa-user",
        )
        admin.add_view(view)
        admin.add_view(
            FileAdmin(
                os.path.dirname(__file__),
                "/files/",
                name="Local Files",
                menu_icon_type="fa",
                menu_icon_value="fa-folder",
            )
        )

        client = app.test_client()

        rv = client.get("/admin/model1/")
        data = rv.data.decode("utf-8")
        assert match_page_title_and_icon(
            data, "My Model1", '<i class="fa fa-user"></i>'
        )

        rv = client.get("/admin/model1/edit/?id=1")
        data = rv.data.decode("utf-8")
        assert match_page_title_and_icon(
            data, "My Model1", '<i class="fa fa-user"></i>'
        )

        rv = client.get("/admin/model1/new/?id=1")
        data = rv.data.decode("utf-8")
        assert match_page_title_and_icon(
            data, "My Model1", '<i class="fa fa-user"></i>'
        )

        rv = client.get("/admin/model1/details/?id=1")
        data = rv.data.decode("utf-8")
        assert match_page_title_and_icon(
            data, "My Model1", '<i class="fa fa-user"></i>'
        )

        rv = client.get("/admin/fileadmin/")
        data = rv.data.decode("utf-8")
        assert match_page_title_and_icon(
            data, "Local Files", '<i class="fa fa-folder"></i>'
        )


@pytest.mark.xfail(
    reason="SQLALiteProvider does not support passing db.session directly",
    raises=TypeError,
    strict=True,
)
def test_sqlalite_session_raises(app, sqla_db_ext):
    if sqla_db_ext.__class__.__name__ != "SQLALiteProvider":
        pytest.skip("Only relevant for SQLALiteProvider")

    Model = create_models(sqla_db_ext)
    with app.app_context():
        sqla_db_ext.create_all()

    ModelView(Model, sqla_db_ext.db.session)
