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

Incorrect URLs with multiple views on the same model #681

Open
2 tasks done
5p4k opened this issue Dec 8, 2023 · 15 comments
Open
2 tasks done

Incorrect URLs with multiple views on the same model #681

5p4k opened this issue Dec 8, 2023 · 15 comments

Comments

@5p4k
Copy link

5p4k commented Dec 8, 2023

Checklist

  • The bug is reproducible against the latest release or master.
  • There are no similar issues or pull requests to fix it yet.

Describe the bug

The usage case is to provide two different views with list_query and slightly different templates.
Having two views on the same model requires to provide a different name and identity to the second view to avoid a crash.

However, both links in the menu point to the same ModelView, so only one of the two views is usable.

Side note: if a name is omitted in the second view, we get a TypeError in Menu.add (called from BaseAdmin._build_menu), which could probably be caught in advance to yield a more meaningful exception, such as "Multiple views on the same model must have different names".

Steps to reproduce the bug

MVE:

from sqlalchemy import Column, Integer, String, create_engine, select, Select, func
from sqlalchemy.orm import declarative_base, Session

from fastapi import FastAPI
from sqladmin import Admin, ModelView
from starlette.requests import Request

Base = declarative_base()
engine = create_engine(
    "sqlite:///example.db",
    connect_args={"check_same_thread": False},
)


class User(Base):
    __tablename__ = "users"

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


Base.metadata.create_all(engine)

with Session(engine) as session:
    if session.query(User.id).count() == 0:
        session.add(User(name='Demo1', email='[email protected]'))
        session.add(User(name='Demo2', email='demo2@bad_domain.org'))
        session.commit()


# ------------------------

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


class UserAdmin(ModelView, model=User):
    column_list = [User.id, User.name]


class BadDomainUserAdmin(ModelView, model=User):
    column_list = [User.id, User.name]
    name = 'Bad user'  # omitting this leads to a TypeError
    identity = 'bad_user'

    def list_query(self, request: Request) -> Select:
        return select(User).where(User.email == 'demo2@bad_domain.org')

    def count_query(self, request: Request) -> Select:
        return select(func.count()).select_from(User).where(User.email == 'demo2@bad_domain.org')


admin.add_view(UserAdmin)
admin.add_view(BadDomainUserAdmin)

Expected behavior

There should be two distincts views with distincts URLs (or at least, I didn't find anything suggesting that this in not supported).

Actual behavior

Both menu entries lead to /admin/user/list, instead of /admin/bad_user/list.

Debugging material

This seems to be due to identity being overwritten by the slugified model name:

cls.identity = slugify_class_name(model.__name__)

Changing the line to

 cls.identity = attrs.get("identity", slugify_class_name(model.__name__))

seems to fix it at least at first glance, however, identity is referenced both in the templates and in models.py as a parameter to build urls, so this is not sufficient. Indeed, the urls for user edititing and detailed display both point to user instead of bad_user.

Environment

  • Linux Mint 21.1 Vera
  • python 3.10.12
  • SQLAdmin 0.16.0

Additional context

The intention was to provide a customized list view for certain items in the table (so, listing only, no detail/edit view) with a custom form widget to allow the user to perform an action on some of these items.

@aminalaee
Copy link
Owner

You are right in most of the assumptions, and yes this usage is not intended, so I think you have two options:

  • create a custom view for this model, needs a bit of work
  • right now SQLAdmin doesn't have filtering, but if you could do dynamic filter on the Users, then you wouldn't need to implement this custom view, right?

@5p4k
Copy link
Author

5p4k commented Dec 12, 2023

I see; I might try to create a custom view in the future.

The elements available in the default templates (like the list view) are quite powerful, it would be nice to be able to reuse them, and have some examples in the documentation on how to do so.

if you could do dynamic filter on the Users, then you wouldn't need to implement this custom view, right?

In the actual application I would still go with a different view, mostly because the UI displayed for the filtered items is quite different.

For context, the table would contain a list of items, some of them in a "pending" state, and most of them in a "processed". The few pending items would have a few actions available on them and the ability to modify them, while the many processed items are there for later double-check and consultation, and are read-only. There are many ways to go about this, including creating two different tables, however the first attempt I made was to produce two different filtered views of the same table.

@glarione
Copy link

glarione commented Apr 7, 2024

I faced the same requirement. I can propose next solution:
in models.py::ModelViewMeta:

-        cls.identity = slugify_class_name(model.__name__)
+        cls.identity = kwargs.get("identity", slugify_class_name(model.__name__))

So the usage is:

# for custom view we use identity in params
class AllMessagesView(ModelView, model=SomeMessage, identity="all_messages"):
    ...
# default view is the same
class UserMessagesView(ModelView, model=SomeMessage):
    ...

@MitPitt
Copy link

MitPitt commented May 2, 2024

I had a ton of trouble with this. I finally found a workaround: create a new inherited class. E.g.:

class View1(ModelView, model=User): ...

class UserForNewView(User): ...

class View2(ModelView, model=UserForNewView): ...

Now all URLs will work correctly in view2 because it has a brand new __class__.__name__

No use of identity is necessary. identity always breaks URLs if you change it to anything other than class name.

@glarione
Copy link

glarione commented May 3, 2024

@MitPitt Thanks! Your workaround is much neater than mine

@aminalaee
Copy link
Owner

Any suggestions how to implement this to avoid such problems? Open to ideas and PRs.

@MitPitt
Copy link

MitPitt commented May 3, 2024

Any suggestions how to implement this to avoid such problems? Open to ideas and PRs.

If identity is specified, inherit from sqlalchemy model class using identity as new class name.

The example I wrote above can be written in one line:

class View(ModelView, model=type("banned_users", (User,), {})): ...

I want this to be the equivalent of:

class View(ModelView, model=User, identity="banned_users"): ...

And if no identity is specified it would take slugified class name of User ("user"), as usual.

If 2 views have the same identity, an exception should be raised.

Currently identity is broken so you shouldn't worry about changing the logic around it.

@jacobgarder
Copy link

Not sure if this is what @MitPitt suggested, but could we not make the changes in models.py and fetch the identity from the request-object instead of the row?

    def _build_url_for(self, name: str, request: Request, obj: Any) -> URL:
        return request.url_for(
            name,
-            identity=slugify_class_name(obj.__class__.__name__),
+            identity=request.path_params.get("identity", slugify_class_name(obj.__class__.__name__)),
            pk=get_object_identifier(obj),
        )

I'm happy to make the PR if it makes sense.

@aminalaee
Copy link
Owner

I think you can give it a try, reproducing it should be simple with this example: #681 (comment)

@aminalaee
Copy link
Owner

aminalaee commented May 6, 2024

I just tested around and remembered my last attempt why I didn't implement this. The URL builder can't always rely on request.path_params because sometimes we build URL from one model to point to another one, not always pointing to the same model, like the following example:

Screenshot 2024-05-06 at 14 06 48 And in this case there will be a side-effect, that when we are building a URL for Parent, from Child, we have two views for Parent, it's not clear which one to point to. I'm not sure if this is a desirable side-effect or not so I look forward to your input and how you use this library.

The link to Parent can is to the details of a single parent so it might be that it doesn't matter which view we are pointing to, but it is clear that we can't figure out which view we should use for Parent by default.

@jacobgarder
Copy link

jacobgarder commented May 7, 2024

Yes I noticed the same thing when running some tests (I myself does not use relationship-models).
To solve this, I think we need to use different ways to generate the url in the different places.

To solve the problem with the identity being derived from the model-name, I think we need to have a similar solution to what @glarione suggested and in that case, you need to create the second view-class like :

class BadDomainUserAdmin(ModelView, model=User, identity="bad_user"):
    column_list = [User.id, User.name]
    name = "Bad user"  # omitting this leads to a TypeError

    def list_query(self, request: Request) -> Select:
        return select(User).where(User.email == "demo2@bad_domain.org")

Otherwise the list_query() in the example will not be used for the listing either (as it will use the UserAdmin-view and not BadDomainUserAdmin.
Screenshot 2024-05-08 at 10 15 51

The other way to do it (and what I have done so far) is to create a "baseview" with the model and basic-settings you want and then inherit that view like:

class UserAdmin(ModelView, model=User):
    column_list = [User.id, User.name]

class InheritedUserAdmin(UserAdmin):
    column_list = [User.name, User.email]
    name = "inherited"  # omitting this leads to a TypeError
    name_plural = "inherited users" # to have a different name in the sidebar
    identity = "inherited"

    def list_query(self, request: Request) -> Select:
        return select(User).where(User.email == "demo2@bad_domain.org")

Screenshot 2024-05-08 at 09 58 39

I filed a PR #759 as a start with some changes that addresses these problems.

@aminalaee
Copy link
Owner

aminalaee commented May 8, 2024

Thanks for the initiative, but I'm still not sure if this is the best way to tackle this.

Even with this PR, it's still needs some hacking to make it work and that definitely is not ideal. Django for example simply raises an error and doesn't allow registering a model twice. I'm interested to see what is the flask-admin behaviour in this case.

Maybe it's still better to implement filters so one can filter records instead of providing two views.

@jacobgarder
Copy link

I agree the identity-hack probably should be solved some other way, removing it from PR.

As the PR is now, it works if you work with inheritance, if inheriting the view. This way (with inherited views like above) also works in flask-admin.

Flask-admin has the endpoint attribute that is used to generate urls.. But also note that in flask-admin we call the add_view with an instance of the view-class add_view(User()) and not the class as in sql-admin add_view(User)...

flask-admin:

admin.add_view(UserAdmin(User, db.session, name="List", endpoint="list"))
admin.add_view(InheritedUserAdmin(User, db.session, name="Bad User List", endpoint="baduser"))

vs in sql-admin:

admin.add_view(UserAdmin)
admin.add_view(InheritedUserAdmin)

@aminalaee
Copy link
Owner

aminalaee commented May 8, 2024

Interesting, it is kind of similar to the identity we have, we just get it in a different way, but probably flask-admin is storing the endpoint:model mapping somewhere so it can do the mapping. I think the same could be achieved here, but we should also check how the URL building is done from one model to the other in flask admin.

For example we could do:

class UserAdmin(ModelView, model=User, identity="user"): ...
class AnotherUserAdmin(ModelView, model=User, identity="another_user"): ...
class AddressAdmin(ModelView, model=Address, identity="address"): ...

# register ...

How would the URL from Address know to which User view point to.

@hasansezertasan
Copy link
Contributor

hasansezertasan commented May 17, 2024

Interesting, it is kind of similar to the identity we have, we just get it in a different way, but probably flask-admin is storing the endpoint:model mapping somewhere so it can do the mapping. I think the same could be achieved here, but we should also check how the URL building is done from one model to the other in flask admin.

Flask Admin registers one blueprint per view. The model is stored in the view and each model view has its own endpoint functions, which they have access the the model, finally these endpoint functions are registered to the blueprint on the view.

For example we could do:

class UserAdmin(ModelView, model=User, identity="user"): ...
class AnotherUserAdmin(ModelView, model=User, identity="another_user"): ...
class AddressAdmin(ModelView, model=Address, identity="address"): ...

# register ...

How would the URL from Address know to which User view point to.

On the other hand, Flask AppBuilder builds the urls almost as is (view.__name__.lower()).

At #77 I proposed using Router per view and moving edit, create, detail, etc. endpoints to the ModelView class (similar to Flask Admin).

I believe using Flask Admin approach over endpoint mapping approach could be quite good for uses cases like this one.

I saw this endpoint mapping approach (mentioned above) used in the Starlette Admin library as well. I tried to implement router per view on Starlette Admin but failed (I'll look into it again tomorrow).

I would like to knock what is the motivation behind this approach?

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

No branches or pull requests

6 participants