Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug fix: unhandled exception during AjaxSelect load #727

Merged
merged 1 commit into from
Sep 5, 2024

Conversation

diskream
Copy link
Contributor

@diskream diskream commented Mar 11, 2024

There is an unhandled exception in AjaxSelect2Widget after any exception in model_view.insert_model.

The error occurs when you pick something in the Ajax select box and there is an exception in the overridden insert_model method. Here is a part of the traceback:

  File "/Users/diskream/Library/Caches/pypoetry/virtualenvs/aMY0E6K2-py3.12/lib/python3.12/site-packages/sqladmin/widgets.py", line 57, in __call__
    data = field.loader.format(field.data)
           │     │      │      │     └ <property object at 0x110181300>
           │     │      │      └ <sqladmin.fields.AjaxSelectField object at 0x110903b90>
           │     │      └ <function QueryAjaxModelLoader.format at 0x1101089a0>
           │     └ <sqladmin.ajax.QueryAjaxModelLoader object at 0x1101a0b30>
           └ <sqladmin.fields.AjaxSelectField object at 0x110903b90>
  File "/Users/diskream/Library/Caches/pypoetry/virtualenvs/aMY0E6K2-py3.12/lib/python3.12/site-packages/sqladmin/ajax.py", line 59, in format
    return {"id": str(get_object_identifier(model)), "text": str(model)}
                      │                     │                    └ '1'
                      │                     └ '1'
                      └ <function get_object_identifier at 0x110108360>
  File "/Users/diskream/Library/Caches/pypoetry/virtualenvs/aMY0E6K2-py3.12/lib/python3.12/site-packages/sqladmin/helpers.py", line 183, in get_object_identifier
    primary_keys = get_primary_keys(obj)
                   │                └ '1'
                   └ <function get_primary_keys at 0x1101082c0>
  File "/Users/diskream/Library/Caches/pypoetry/virtualenvs/aMY0E6K2-py3.12/lib/python3.12/site-packages/sqladmin/helpers.py", line 178, in get_primary_keys
    return tuple(inspect(model).mapper.primary_key)
                 │       └ '1'
                 └ <function inspect at 0x106b33f60>
  File "/Users/diskream/Library/Caches/pypoetry/virtualenvs/aMY0E6K2-py3.12/lib/python3.12/site-packages/sqlalchemy/inspection.py", line 147, in inspect
    raise exc.NoInspectionAvailable(
          │   └ <class 'sqlalchemy.exc.NoInspectionAvailable'>
          └ <module 'sqlalchemy.exc' from '/Users/diskream/Library/Caches/pypoetry/virtualenvs/aMY0E6K2-py3.12/lib/python3.1...

sqlalchemy.exc.NoInspectionAvailable: No inspection system is available for object of type <class 'str'>

As you can see, the widget receives a string from the form, not the model. A common approach is to handle exceptions at the time of parsing the form.

Here is snippet to reproduce error:

from typing import Any

from sqlalchemy import Column, Integer, ForeignKey, String
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import declarative_base, sessionmaker, relationship
from starlette.applications import Starlette
from starlette.requests import Request

from sqladmin import Admin, ModelView

async_engine = create_async_engine("sqlite+aiosqlite:///test.db")

Base = declarative_base()  # type: Any
session_maker = sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False)

app = Starlette()


@app.on_event("startup")
async def startup() -> None:
    async with async_engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)


admin = Admin(app=app, engine=async_engine)


class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    name = Column(String(length=16))

    addresses = relationship("Address", back_populates="user")

    def __str__(self) -> str:
        return f"User {self.id}"


class Address(Base):
    __tablename__ = "addresses"

    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"))

    user = relationship("User", back_populates="addresses")

    def __str__(self) -> str:
        return f"Address {self.id}"


class UserAdmin(ModelView, model=User):
    form_columns = ("name",)
    form_ajax_refs = {
        "addresses": {
            "fields": ("id",),
        }
    }


class AddressAdmin(ModelView, model=Address):
    form_ajax_refs = {
        "user": {
            "fields": ("name",),
            "order_by": ("id"),
        }
    }

    async def insert_model(self, request: Request, data: dict) -> Any:
        1 / 0


admin.add_view(UserAdmin)
admin.add_view(AddressAdmin)

if __name__ == "__main__":
    import uvicorn

    uvicorn.run(app, host="127.0.0.1", port=8000)

Copy link
Owner

@aminalaee aminalaee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR, I have a question since there's no issue related to this.

@diskream
Copy link
Contributor Author

Thanks for the PR, I have a question since there's no issue related to this.

I decided to make a PR, not issue because I already have some workaround about it and provided snippet to reproduce. Should I have created an issue first?

@aminalaee
Copy link
Owner

Sorry I think my comment was discarded, my question was that how is this going to fix or workaround the issue?
This will just ignore the exception right?

@diskream
Copy link
Contributor Author

Yes, my solution is to clear ajax field if there is any error on field loading. May be this is not the best solution, but due to there was no exception handling, I think there is no need to do something more serious here.

@VOINTENT
Copy link

VOINTENT commented Jun 5, 2024

I agree. These changes will not have any negative side effects. It would be good to see this fix in the main branch.

@Mat0mba24
Copy link

@aminalaee

I have the same problem, how soon will it be fixed?

This my code:

class FileAdmin(ModelView, model=File):
    name = "Файл для игры"
    name_plural = "Файлы для игры"
    icon = "fa-solid fa-file"
    column_list = [
        File.file_id,
        File.path,
    ]
    form_columns = [
        File.game,
        File.path
    ]
    form_ajax_refs = {
        "game": {
            "fields": ("game_id", "order_id", "keyword"),
            "order_by": "game_id"
        }
    }

    async def on_model_change(self, data: dict, model: Mailing, is_created: bool, request: Request):
        if regex.search(pattern=r"\p{IsCyrillic}", string=data["path"].filename):
            raise ValueError(
                "Название файла не должно содержать кириллицу."
            )

When a user uses Cyrillic in the file names, a ValueError exception is thrown, but it cannot be handled properly:

sqlalchemy.exc.NoInspectionAvailable: No inspection system is available for object of type <class 'str'>

And the admin panel does not load:
image
image
image

@aminalaee
Copy link
Owner

@Mat0mba24 I don't think your issue is related to ajax, you are raising the error in on_model_change method.

@diskream
Copy link
Contributor Author

@aminalaee it is.I debugged the admin on error raising in insert_model, update_model and delete_model when Ajax was turned on. It was in the Ajax field load that received a field value (in @Mat0mba24 case it was a Cyrillic string), instead of the Ajax field. I have provided a snippet to reproduce the issue.

@Mat0mba24
Copy link

Mat0mba24 commented Jul 10, 2024

@aminalaee

I'm removing ajax:

class FileAdmin(ModelView, model=File):
    name = "Файл для игры"
    name_plural = "Файлы для игры"
    icon = "fa-solid fa-file"
    column_list = [
        File.file_id,
        File.path,
    ]
    form_columns = [
        File.game,
        File.path
    ]
    # form_ajax_refs = {
    #     "game": {
    #         "fields": ("game_id", "order_id", "keyword"),
    #         "order_by": "game_id"
    #     }
    # }

    async def on_model_change(self, data: dict, model: File, is_created: bool, request: Request):
        if regex.search(pattern=r"\p{IsCyrillic}", string=data["path"].filename):
            raise ValueError(
                "Название файла не должно содержать кириллицу."
            )

And everything works as intended:
image

I'm returning it:

class FileAdmin(ModelView, model=File):
    name = "Файл для игры"
    name_plural = "Файлы для игры"
    icon = "fa-solid fa-file"
    column_list = [
        File.file_id,
        File.path,
    ]
    form_columns = [
        File.game,
        File.path
    ]
    form_ajax_refs = {
        "game": {
            "fields": ("game_id", "order_id", "keyword"),
            "order_by": "game_id"
        }
    }

    async def on_model_change(self, data: dict, model: File, is_created: bool, request: Request):
        if regex.search(pattern=r"\p{IsCyrillic}", string=data["path"].filename):
            raise ValueError(
                "Название файла не должно содержать кириллицу."
            )

And again the same mistake:
image

  File "C:\Users\devxd\PycharmProjects\Aiogram3\aiogram3_words_tg_bot\venv\Lib\site-packages\sqladmin\helpers.py", line 176, in get_primary_keys
    return tuple(inspect(model).mapper.primary_key)
                 ^^^^^^^^^^^^^^
  File "C:\Users\devxd\PycharmProjects\Aiogram3\aiogram3_words_tg_bot\venv\Lib\site-packages\sqlalchemy\inspection.py", line 147, in inspect
    raise exc.NoInspectionAvailable(
sqlalchemy.exc.NoInspectionAvailable: No inspection system is available for object of type <class 'str'>

@aminalaee
Copy link
Owner

aminalaee commented Jul 11, 2024

It was a bit confusing but at least I know what the issue is now.
Theform_ajax_refs creates the special HTML form and widget with Select2. When there's an error with this form, we only have the string PK of the model, and we don't have access to the User object, in this case.

The suggested solution works around this, but it causes a side-effect that when we are redirected to the form again, we lose the state of this select2 input because we set data to None.

I think ideally we could force select2 to pass ID and Text of the selected item to backend so we could keep the input state in case of error.

@aminalaee aminalaee force-pushed the ajax_field_error_fix branch from 0283654 to d0f3d01 Compare September 5, 2024 06:58
Copy link
Owner

@aminalaee aminalaee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM for a temporary workaround until it is fixed properly

@aminalaee aminalaee merged commit e1c7ae1 into aminalaee:main Sep 5, 2024
5 checks passed
@aminalaee aminalaee mentioned this pull request Sep 6, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants