gabotorresruiz commented on code in PR #45036:
URL: https://github.com/apache/superset/pull/45036#discussion_r4210794412


##########
superset/models/dashboard.py:
##########
@@ -98,23 +107,56 @@ def copy_dashboard(_mapper: Mapper, _connection: 
Connection, target: Dashboard)
         json_metadata=template.json_metadata,
         slices=template.slices,
         editors=editors,
-        # Resolved from the in-memory collection: this runs in ``after_insert``
-        # for the user, before their ``ab_user_group`` rows are written.
         viewers=get_default_viewers_for_groups(
             list(getattr(new_user, "groups", []) or [])
         ),
     )
     session.add(dashboard)
-
-    # set dashboard as the welcome dashboard
+    session.flush()
     extra_attributes = UserAttribute(
         user_id=target.id, welcome_dashboard_id=dashboard.id
     )
     session.add(extra_attributes)
     session.commit()  # pylint: disable=consider-using-transaction
 
 
-sqla.event.listen(User, "after_insert", copy_dashboard)
+def copy_dashboard(_mapper: Mapper, _connection: Connection, target: Any) -> 
None:
+    """SQLAlchemy mapper event hook triggered on user creation (after_insert).
+
+    Creates a personal copy of the configured template dashboard for the user.
+    Uses an isolated session bound to the active transaction connection to 
avoid
+    SQLAlchemy FlushError or overlapping flush issues during the flush 
lifecycle.
+
+    :param _mapper: The SQLAlchemy mapper for the entity.
+    :param _connection: The active database connection for the event.
+    :param target: The user entity instance being inserted.
+    """
+    dashboard_id = app.config["DASHBOARD_TEMPLATE_ID"]
+    if dashboard_id is None:
+        return
+
+    # Check if sqla.inspect is mocked (for compatibility with legacy tests)
+    if hasattr(sqla.inspect, "mock_calls"):
+        inspected = sqla.inspect(target)
+        target_session = getattr(inspected, "session", None)
+        if target_session is not None:
+            _copy_dashboard_for_user(target_session, target, dashboard_id)
+            return
+
+    with Session(bind=_connection) as session:  # pylint: 
disable=disallowed-name

Review Comment:
   This block worries me a bit. The `Dashboard` built just above goes into this 
new session, but its `editors` and `viewers` are `Subject` instances resolved 
on `db.session`: `get_user_subject` queries `db.session` 
(`superset/subjects/utils.py:271`) and `subjects_from_groups` does the same 
(`superset/subjects/utils.py:410`). A persistent object cannot be cascaded into 
a second session, so `session.add(dashboard)` plus `session.flush()` raises as 
soon as either list is non-empty.
   
   It stays quiet on this branch only because both lists come back empty today 
(I measured `editors=[] viewers=[]` on a real insert). To check the path rather 
than the emptiness I left your code untouched and only made `get_user_subject` 
return a real `db.session` attached `Subject`, which is what it does whenever 
the user's subject row exists. `sm.add_user(...)` then fails:
   
   ```
   ERROR:flask_appbuilder.security.sqla.manager:Error adding new user to 
database.
   Object '<Subject at 0x...>' is already attached to session '3' (this is '5')
   ```
   
   FAB swallows that and `add_user` returns `False`, so no user and no copy are 
written. The same thing happens if the `viewers` line is fixed to read the 
groups off the in-memory `target`, which is the fix for the bot's comment 
above, so closing that one uncovers this one.
   
   Going back to the flushing session is not the way out either, I tried it and 
got `InvalidRequestError: Session is already flushing` swallowed the same way. 
What worked for me was keeping your isolated session and re-fetching the 
subjects inside it:
   
   ```python
   from superset.subjects.models import Subject
   
   def _rebind(subjects: list[Subject | None]) -> list[Subject]:
       """The helpers resolve subjects on ``db.session``; a persistent object
       cannot be cascaded into a second session."""
       ids = [s.id for s in subjects if s is not None]
       return session.query(Subject).filter(Subject.id.in_(ids)).all() if ids 
else []
   
   editors = _rebind([get_user_subject(target.id)])
   viewers = _rebind(
       get_default_viewers_for_groups(list(getattr(target, "groups", []) or []))
   )
   ```
   
   With that, a user inserted carrying one group that has a `Subject` row 
writes cleanly and the copy comes out with `viewers=[3]`, which is the 
behaviour 
`tests/unit_tests/subjects/test_creator_group_call_sites.py::test_copy_dashboard_attaches_viewers_from_the_users_in_memory_groups`
 exists to pin.
   
   For the regression test I think it has to use a real session: insert a user 
with one group that has a `Subject` row, `DASHBOARD_TEMPLATE_ID` pointing at a 
real dashboard, then assert the copy's `viewers` is that group's subject. A 
`Mock` session cannot fail this way, which is why the new tests here pass.
   



##########
superset/models/dashboard.py:
##########
@@ -98,23 +107,56 @@ def copy_dashboard(_mapper: Mapper, _connection: 
Connection, target: Dashboard)
         json_metadata=template.json_metadata,
         slices=template.slices,
         editors=editors,
-        # Resolved from the in-memory collection: this runs in ``after_insert``
-        # for the user, before their ``ab_user_group`` rows are written.
         viewers=get_default_viewers_for_groups(
             list(getattr(new_user, "groups", []) or [])
         ),
     )
     session.add(dashboard)
-
-    # set dashboard as the welcome dashboard
+    session.flush()
     extra_attributes = UserAttribute(
         user_id=target.id, welcome_dashboard_id=dashboard.id
     )
     session.add(extra_attributes)
     session.commit()  # pylint: disable=consider-using-transaction
 
 
-sqla.event.listen(User, "after_insert", copy_dashboard)
+def copy_dashboard(_mapper: Mapper, _connection: Connection, target: Any) -> 
None:
+    """SQLAlchemy mapper event hook triggered on user creation (after_insert).
+
+    Creates a personal copy of the configured template dashboard for the user.
+    Uses an isolated session bound to the active transaction connection to 
avoid
+    SQLAlchemy FlushError or overlapping flush issues during the flush 
lifecycle.
+
+    :param _mapper: The SQLAlchemy mapper for the entity.
+    :param _connection: The active database connection for the event.
+    :param target: The user entity instance being inserted.
+    """
+    dashboard_id = app.config["DASHBOARD_TEMPLATE_ID"]
+    if dashboard_id is None:
+        return
+
+    # Check if sqla.inspect is mocked (for compatibility with legacy tests)
+    if hasattr(sqla.inspect, "mock_calls"):

Review Comment:
   This is the one I would most like to see go. It makes production behaviour 
depend on whether `sqlalchemy.inspect` happens to be a `unittest.mock` object, 
and what it keeps alive is 
`tests/unit_tests/subjects/test_creator_group_call_sites.py::test_copy_dashboard_attaches_viewers_from_the_users_in_memory_groups`.
 I checked by deleting just these seven lines: that test is the only one in the 
file that then fails.
   
   The effect is that the one test asserting "viewers are resolved from the 
in-memory collection, never by querying membership" keeps running the old 
`sqla.inspect(target).session` path, while every real insert takes the 
`Session(bind=_connection)` path below, where `new_user` is a fresh DB load and 
its `ab_user_group` rows have not been written yet. So the invariant stays 
green in CI and stops holding at runtime. Measured on this branch with 
`ENABLE_VIEWERS` and `ASSIGN_CREATOR_GROUPS_AS_VIEWERS` on, a configured 
template, and a user inserted with one group that has a `Subject` row: the copy 
comes out `viewers=[]`, where reading the groups off `target` gives 
`viewers=[3]`.
   
   Could we drop the branch and rewrite that test against the new structure? It 
patches `dashboard_module.sqla.inspect` and `dashboard_module.Dashboard`, 
neither of which the new path uses, so it needs updating either way.
   



##########
tests/unit_tests/models/dashboard_test.py:
##########
@@ -737,3 +740,190 @@ def 
test_datasets_trimmed_for_slices_keeps_colliding_ids_separate() -> None:
     # with the exact slice list (no semantic-view slice leaking into it).
     assert result == [(table_datasource, {"cols": ["column"]})]
     
table_datasource.data_for_slices.assert_called_once_with([sesh_table_slice])
+
+
+def test_custom_user_model_dashboard_copy_listener(app_context: None) -> None:
+    """Ensure dashboard copy events can be registered dynamically
+    for custom user models.
+    """
+    from superset.models.dashboard import register_dashboard_copy_events
+
+    assert callable(register_dashboard_copy_events)
+
+
+class CustomUserModel(User):
+    """Custom user model subclass for testing dynamic security manager 
models."""
+
+    __tablename__ = "ab_user"
+    custom_field = "custom_value"
+
+
[email protected]
+def custom_user_model() -> type[CustomUserModel]:
+    """Fixture providing a mock CustomUserModel class."""
+    return CustomUserModel
+
+
[email protected]
+def mock_dashboard_template() -> Dashboard:
+    """Fixture providing a mock template Dashboard instance."""
+    dash = Dashboard()
+    dash.id = 100
+    dash.dashboard_title = "Template Dashboard"
+    dash.position_json = "{}"
+    dash.description = "Template description"
+    dash.css = ""
+    dash.json_metadata = "{}"
+    dash.slices = []
+    return dash
+
+
+def test_exclude_users_filter_with_custom_user_model(

Review Comment:
   Just so you know, this one passes unchanged on the merge base (`a5ffac5`), 
so it is not pinning the `ExcludeUsersFilter` change. 
`CustomUserModel.username` and `User.username` resolve to the same 
`ab_user.username` column here, so `filter_arg.left` matches either way.
   
   I copied all six new tests onto the base out of curiosity: four fail there 
and all four fail on `ImportError: cannot import name 
'register_dashboard_copy_events'` or `AttributeError: module 
'superset.models.dashboard' does not have the attribute 'Session'`, which is 
the new symbols not existing yet rather than the bug reproducing. None of them 
drives a real session or a real alternative user model, which is why the 
cross-session problem I flagged on `superset/models/dashboard.py:146` is 
invisible to them.
   



##########
superset/security/manager.py:
##########
@@ -490,14 +490,61 @@ class ExcludeUsersFilter(BaseFilter):  # pylint: 
disable=too-few-public-methods
     name = _("username")
     arg_name = "username"
 
+    @staticmethod
+    def _is_mock(obj: Any) -> bool:

Review Comment:
   Same family as the `sqla.inspect` branch, and I would rather `unittest.mock` 
not be imported into the security manager at all. `_get_username_column` runs 
on the user list request path through `SupersetUserApi.base_filters`, so this 
makes a request path behave differently for tests.
   
   I checked what it is protecting and it is three of the four tests in 
`tests/unit_tests/security/exclude_users_filter_test.py`, which patch 
`superset.security.manager.current_app` with a bare `MagicMock()`. With 
`_is_mock` short-circuited to `False` they fail with `ArgumentError: SQL 
expression for WHERE/HAVING role expected, got <MagicMock 
name='mock.appbuilder.sm.user_model.username.not_in()'>`. Adding one line, 
`mock_sm.user_model = User`, to each of those three makes all four pass with 
`_is_mock` and `_get_username_column` deleted outright. I ran it: `4 passed`.
   
   Which raises whether the `ExcludeUsersFilter` change belongs in this PR at 
all. For the model shape in #45023 (a subclass with `__tablename__ = 
"ab_user"`) I could not find a behaviour difference: 
`User.username.not_in([...])` and `CustomUser.username.not_in([...])` both 
compile to `(ab_user.username NOT IN ('x'))`. Dropping it would take 45 lines 
out of `manager.py` and keep this PR on the template copy bug, which is the 
part I think is genuinely broken.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to