From f287d9ca6921bf0156662f379f964bb198c811e7 Mon Sep 17 00:00:00 2001 From: borhanst Date: Tue, 1 Sep 2026 17:38:10 +0600 Subject: [PATCH 01/14] fix: improve changelog workflow steps and ensure proper branch checkout --- .github/workflows/changelog.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index dbe2044..45a7fa9 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -11,12 +11,18 @@ permissions: jobs: changelog: runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v4 + # Checkout the actual branch instead of leaving the repository + # in a detached HEAD state. + - name: Checkout repository + uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.head_ref || github.ref_name }} - - uses: actions/setup-python@v5 + - name: Setup Python + uses: actions/setup-python@v5 with: python-version: "3.12" @@ -24,7 +30,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - run: python scripts/generate_changelog.py --output CHANGELOG.md + run: | + python scripts/generate_changelog.py --output CHANGELOG.md - name: Commit and push changes run: | @@ -32,8 +39,12 @@ jobs: echo "No changelog changes to commit." exit 0 fi + git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "docs: sync CHANGELOG.md from GitHub releases" - git push + + git push origin HEAD:${{ github.ref_name }} From 9f1ed70da4da77f4b288410d8e93624904879f14 Mon Sep 17 00:00:00 2001 From: borhanst Date: Tue, 1 Sep 2026 17:41:47 +0600 Subject: [PATCH 02/14] feat: implement custom auth_model support and adapt built-in user relations --- fastapi_admin_kit/admin/admin_database.py | 15 ++ fastapi_admin_kit/admin/core.py | 50 ++++- fastapi_admin_kit/backends/memory.py | 10 + fastapi_admin_kit/backends/protocols.py | 18 ++ fastapi_admin_kit/backends/sqlalchemy.py | 223 +++++++++++++++++++++- tests/test_schema_materialization.py | 24 ++- 6 files changed, 327 insertions(+), 13 deletions(-) diff --git a/fastapi_admin_kit/admin/admin_database.py b/fastapi_admin_kit/admin/admin_database.py index d0d0707..308a5c5 100644 --- a/fastapi_admin_kit/admin/admin_database.py +++ b/fastapi_admin_kit/admin/admin_database.py @@ -94,6 +94,21 @@ async def _create_tables( exclude = set(extra_exclude_tables or ()) + # If the configured backend cloned the project's auth_model table + # into AdminBase.metadata (so its FK can resolve within the same + # MetaData), drop the clone from the create_all table list — the + # project's own metadata owns the real table and DDL for it + # would conflict. Check both the backend and the database + # instance for the cloned names (the backend stores them on the + # database when available, which is the more reliable path for + # tests that swap the backend). + cloned = set() + backend = getattr(self, "_backend", None) + if backend is not None: + cloned = getattr(backend, "_cloned_auth_tables", set()) or set() + cloned |= getattr(self, "_cloned_auth_tables", set()) or set() + exclude |= cloned + def _filtered(metadata: Any) -> Any: drop = exclude | (set() if include_ai_tables else AI_TABLE_NAMES) if not drop: diff --git a/fastapi_admin_kit/admin/core.py b/fastapi_admin_kit/admin/core.py index 05e5200..a770bbd 100644 --- a/fastapi_admin_kit/admin/core.py +++ b/fastapi_admin_kit/admin/core.py @@ -1481,6 +1481,18 @@ def _builtin_user_tables_to_skip(self) -> tuple[str, ...]: """Return the names of built-in tables that should NOT be created when a custom ``auth_model`` is configured. + When the project supplies its own user model (``auth_model=``), the + built-in ``admin_users`` table is skipped — the custom auth_model + is the source of truth for user identity. ``admin_user_roles`` is + kept and its ``user_id`` foreign key is retargeted to the custom + auth_model's table at create time (see + ``SqlAlchemyDatabaseBackend.adapt_auth_model``) so the junction + links roles to the project's own user rows. + + The role/permission system (``admin_roles``, + ``admin_role_permissions``, ``admin_permissions``) is kept so the + admin can still manage granular per-table access. + Returns an empty tuple when the built-in ``User`` is in use (default installation) or when no ``auth_model`` is configured. """ @@ -1489,7 +1501,7 @@ def _builtin_user_tables_to_skip(self) -> tuple[str, ...]: auth_model = self.config.auth.auth_model if auth_model is None or auth_model is BuiltinUser: return () - return ("admin_users", "admin_user_roles") + return ("admin_users",) async def create_tables(self) -> None: """Create all admin database tables, correctly handling a custom @@ -1522,6 +1534,21 @@ async def create_tables(self) -> None: logger.info("SKIP_CREATE_TABLES=true: skipping admin table creation") return self._adapt_builtin_user_id_columns() + # Ask the configured backend to retarget the built-in user + # relations (admin_user_roles FK, Role.users M2M, etc.) at the + # custom auth_model. Each backend implements this against its own + # ORM primitives; non-SQLA backends may no-op. + backend = self.backend + database_backend = getattr(backend, "database", None) if backend is not None else None + adapt = getattr(database_backend, "adapt_auth_model", None) + if adapt is not None and self.config.auth.auth_model is not None: + try: + from fastapi_admin_kit.migrations.models import User as BuiltinUser + + if self.config.auth.auth_model is not BuiltinUser: + adapt(self.config.auth.auth_model) + except Exception as exc: # pragma: no cover - defensive + logger.warning("adapt_auth_model failed: %s", exc) extra_exclude = list(self._builtin_user_tables_to_skip()) if extra_exclude: logger.info( @@ -1607,6 +1634,18 @@ def _adapt_builtin_user_id_columns(self) -> None: ) col.type = new_type + # Note: the built-in ``admin_users`` table is excluded from + # ``create_all`` at the call site in ``setup()`` via + # ``AdminDatabase._create_tables(extra_exclude_tables=...)`` — we do + # NOT remove it from ``AdminBase.metadata`` here because the + # ``FacadeDict`` exposed by ``Base.metadata`` is immutable. + # + # FK retargeting on ``admin_user_roles`` and the ``Role.users`` M2M + # re-binding are delegated to the configured backend via + # ``backend.adapt_auth_model(auth_model)`` — this keeps ``core.py`` + # ORM-agnostic. Backends (SQLAlchemy) implement the actual column + # and relationship mutations; memory/no-op backends can ignore. + # Note: the built-in ``admin_users`` and ``admin_user_roles`` tables # are excluded from ``create_all`` at the call site in ``setup()`` via # ``AdminDatabase._create_tables(extra_exclude_tables=...)`` — we do @@ -1625,12 +1664,21 @@ def _excluded_builtin_tables(self) -> frozenset[str]: three user-facing AI tables so they never leak into the sidebar/routes. When notifications are disabled, also excludes the notification tables so the "notifications" sidebar group never appears. + + When a custom ``auth_model`` is configured, also excludes the built-in + ``admin_users`` model from auto-discovery (it is never created when a + custom auth_model is set — see ``_builtin_user_tables_to_skip``). The + ``admin_user_roles`` junction table is NOT excluded: it is kept and its + ``user_id`` foreign key is retargeted to the custom auth_model's table + so role relationships still work end-to-end. """ excluded = set(INTERNAL_TABLE_NAMES) # incl. admin_ai_attachments if not self._ai_enabled: excluded |= AI_TABLE_NAMES if not self._enable_notification: excluded |= NOTIFICATION_TABLE_NAMES + if self._builtin_user_tables_to_skip(): + excluded |= {"admin_users"} return frozenset(excluded) def _add_ai_nav_group(self) -> None: diff --git a/fastapi_admin_kit/backends/memory.py b/fastapi_admin_kit/backends/memory.py index f8752b8..7028ac1 100644 --- a/fastapi_admin_kit/backends/memory.py +++ b/fastapi_admin_kit/backends/memory.py @@ -565,6 +565,16 @@ def _init(self: Any, **kwargs: Any) -> None: def session_adapter_class(self) -> type: return MemorySessionBackend + def adapt_auth_model(self, auth_model: Any) -> None: + """No-op for the in-memory backend. + + Memory backend schemas are flat dicts; there is no foreign-key or + relationship machinery to retarget. Custom auth_models work as-is + because the auth layer always queries through the model attribute + lookup, not the table. + """ + return None + # --------------------------------------------------------------------------- # Composite backend diff --git a/fastapi_admin_kit/backends/protocols.py b/fastapi_admin_kit/backends/protocols.py index f4ccb3f..573ec45 100644 --- a/fastapi_admin_kit/backends/protocols.py +++ b/fastapi_admin_kit/backends/protocols.py @@ -278,3 +278,21 @@ def session_adapter_class(self) -> type: directly and never needs this. """ ... + + def adapt_auth_model(self, auth_model: type) -> None: + """Retarget built-in user relations at a custom ``auth_model``. + + Called once during ``Admin.create_tables()`` when a custom + ``auth_model`` is configured. Backends should: + + - retarget the built-in ``admin_user_roles`` user-side foreign key to + the auth_model's table, + - mirror the auth_model's primary-key type onto the + ``admin_user_roles.user_id`` column, + - rebind any M2M relationships that pointed at the built-in + ``admin_users`` model (e.g. ``Role.users``) so joins route to the + custom auth_model. + + Non-SQLA backends (memory, future ODMs) may no-op. + """ + ... diff --git a/fastapi_admin_kit/backends/sqlalchemy.py b/fastapi_admin_kit/backends/sqlalchemy.py index 2b94101..42ca832 100644 --- a/fastapi_admin_kit/backends/sqlalchemy.py +++ b/fastapi_admin_kit/backends/sqlalchemy.py @@ -11,6 +11,7 @@ from __future__ import annotations +import logging from collections.abc import Sequence from typing import TYPE_CHECKING, Any @@ -21,6 +22,8 @@ if TYPE_CHECKING: from fastapi_admin_kit.admin.admin_database import AdminDatabase +logger = logging.getLogger(__name__) + def _is_async_session(session: Any) -> bool: """Return True if *session* is an SQLAlchemy async session.""" @@ -846,6 +849,203 @@ def session_adapter_class(self) -> type: """Class wrapping a raw connection into a :class:`SessionBackend`.""" return SqlAlchemySessionAdapter + def adapt_auth_model(self, auth_model: type) -> None: + """Adapt built-in admin metadata for a custom ``auth_model``. + + Called by ``Admin.create_tables()`` when a project supplies its own + user model. Performs the following ORM-specific adaptations so + the built-in ``admin_user_roles`` junction links roles to the + project's own user table (instead of the built-in ``admin_users`` + which is being skipped): + + 1. Creates a minimal "shadow" table reference for the auth_model + inside ``AdminBase.metadata`` (same table name + PK column), + so the FK on ``admin_user_roles.user_id`` can resolve within + the same ``MetaData`` during ``create_all`` sort. The shadow + is NOT created in the database — the project's own metadata + owns the real table. The shadow's name is recorded on the + backend instance so ``AdminDatabase._create_tables`` can drop + it from the ``create_all`` table list. + 2. Retargets the ``admin_user_roles.user_id`` foreign key from + ``admin_users.id`` to ``.`` and + mirrors the PK column type. Also drops the old + ``ForeignKeyConstraint`` from the junction table's + ``constraints`` collection (DDL is emitted from constraints, + not just ``col.foreign_keys``). + 3. Rebinds the ``Role.users`` M2M relationship onto + *auth_model* so ORM joins route to the project user table. + 4. Re-types the built-in log-pattern ``user_id`` columns on + log-style tables (``admin_audit_log``, notifications, etc.) to + match the custom auth_model's primary-key type. + """ + from sqlalchemy import Column as SA_Column + from sqlalchemy import ForeignKeyConstraint + from sqlalchemy import Table as SA_Table + from sqlalchemy import inspect as sa_inspect + from sqlalchemy.orm import relationship + + from fastapi_admin_kit.migrations.models import ( + Role, + ) + from fastapi_admin_kit.migrations.models import ( + User as BuiltinUser, + ) + + if auth_model is BuiltinUser: + return + + metadata = BuiltinUser.__table__.metadata + + pk_cols = sa_inspect(auth_model).primary_key + if not pk_cols: + return + pk_col = pk_cols[0] + new_type = pk_col.type + auth_table_name = auth_model.__tablename__ + + # 1. Ensure the auth_model's table is resolvable within + # AdminBase.metadata (needed for sort_tables_and_constraints + # to order admin_user_roles after its FK target). If the table + # is already in admin metadata (e.g. the project puts its User + # model on AdminBase), reuse it. Otherwise create a minimal + # shadow table with the same name + PK column. + if auth_table_name not in metadata.tables: + SA_Table( + auth_table_name, + metadata, + SA_Column(pk_col.name, new_type, primary_key=True), + ) + # Record so AdminDatabase._create_tables can drop the shadow from + # the create_all table list (it must not be emitted as DDL). + # Store on the AdminDatabase instance if available so the + # filtering works regardless of backend instance. + db_inst = getattr(self, "_admin_database", None) + if db_inst is not None: + existing = getattr(db_inst, "_cloned_auth_tables", None) + if existing is None: + existing = set() + db_inst._cloned_auth_tables = existing + existing.add(auth_table_name) + if not hasattr(self, "_cloned_auth_tables"): + self._cloned_auth_tables = set() + self._cloned_auth_tables.add(auth_table_name) + + # 2. Retarget the FK on admin_user_roles.user_id + junction = metadata.tables.get("admin_user_roles") + if junction is not None and "user_id" in junction.c: + col = junction.c["user_id"] + + # Drop the old auto-generated ForeignKeyConstraint from the + # table's constraints collection (DDL is emitted from + # constraints, not from col.foreign_keys). Also remove the + # FK objects from the column's foreign_keys set AND from the + # table's foreign_keys set — ``Table.foreign_keys`` is a + # plain ``set`` that aggregates from columns + constraints, + # and stale entries there confuse the M2M relationship + # configuration in step 3. + old_constraints = [ + c + for c in list(junction.constraints) + if isinstance(c, ForeignKeyConstraint) + and any(fk.target_fullname.startswith("admin_users") for fk in c.elements) + ] + for constraint in old_constraints: + junction.constraints.remove(constraint) + for fk in list(constraint.elements): + junction.foreign_keys.discard(fk) + if fk in col.foreign_keys: + col.foreign_keys.discard(fk) + # Belt-and-braces: clear any stale admin_users FK from the + # table-level set. + for fk in list(junction.foreign_keys): + if fk.target_fullname.startswith("admin_users"): + junction.foreign_keys.discard(fk) + + # Add a single ForeignKeyConstraint — its element FK will be + # auto-registered on the column's foreign_keys set, so we do + # NOT add it manually (that would produce duplicates). + new_constraint = ForeignKeyConstraint( + ["user_id"], + [f"{auth_table_name}.{pk_col.name}"], + ondelete="CASCADE", + ) + new_constraint.parent = junction + junction.append_constraint(new_constraint) + col.type = new_type + logger.debug( + "adapt_auth_model: admin_user_roles.user_id -> %s.%s (%r)", + auth_table_name, + pk_col.name, + new_type, + ) + + # 3. Rebind Role.users M2M to the custom auth_model + if hasattr(Role, "users"): + junction_table = metadata.tables.get("admin_user_roles") + auth_has_roles = hasattr(auth_model, "roles") + # Provide explicit primaryjoin/secondaryjoin so SQLAlchemy + # does not have to auto-detect the join across the + # secondary table — the FK on ``admin_user_roles.user_id`` + # now points to a shadow table in admin metadata, which + # makes the auto-detection unreliable. + Role.__mapper__.add_property( + "users", + relationship( + auth_model, + secondary=junction_table, + primaryjoin=Role.id == junction_table.c.role_id, + secondaryjoin=auth_model.__table__.c[pk_col.name] == junction_table.c.user_id, + back_populates="roles" if auth_has_roles else None, + ), + ) + if auth_has_roles: + try: + auth_model.__mapper__.add_property( + "roles", + relationship( + Role, + secondary=junction_table, + primaryjoin=auth_model.__table__.c[pk_col.name] + == junction_table.c.user_id, + secondaryjoin=Role.id == junction_table.c.role_id, + back_populates="users", + ), + ) + except Exception as exc: # pragma: no cover + logger.debug( + "adapt_auth_model: could not add auth_model.roles back-pop: %s", + exc, + ) + logger.debug("adapt_auth_model: Role.users rebound to %s", auth_model) + + # 3b. The built-in User model still has a ``roles`` M2M + # referencing ``admin_user_roles``. Since ``admin_users`` is + # being skipped and the junction's FK now points to the custom + # auth_model, that relationship can no longer auto-resolve. + # Drop it from the mapper so SQLAlchemy does not blow up at + # mapper configuration time. The built-in User is not used in + # the DB when a custom auth_model is configured. + if "roles" in BuiltinUser.__mapper__._props: + BuiltinUser.__mapper__._props.pop("roles", None) + + # 4. Re-type log-pattern user_id columns + log_tables = [ + "admin_audit_log", + "admin_user_permissions", + "admin_refresh_tokens", + "admin_user_totp", + "admin_notifications", + "admin_notification_preferences", + "admin_notification_logs", + "admin_ai_usage_log", + "admin_ai_conversations", + ] + for table_name in log_tables: + table = metadata.tables.get(table_name) + if table is None or "user_id" not in table.c: + continue + table.c["user_id"].type = new_type + def materialize( self, schema: Any, @@ -989,7 +1189,9 @@ def _resolve_target_pk_type(target: Any) -> Any | None: table = md[target] if table is None: if schemas is None: - from fastapi_admin_kit.schemas.builtin import BUILTIN_SCHEMAS + from fastapi_admin_kit.schemas.builtin import ( + BUILTIN_SCHEMAS, + ) reg = schemas if schemas is not None else BUILTIN_SCHEMAS if reg and target in reg: pk = reg[target].get_pk_field() @@ -1096,7 +1298,12 @@ def _resolve_target_pk_type(target: Any) -> Any | None: # Use string-based FK to allow target table to not exist yet columns.append( - Column(f.name, sa_type, ForeignKey(f"{fk_target}.id", use_alter=True), **kwargs) + Column( + f.name, + sa_type, + ForeignKey(f"{fk_target}.id", use_alter=True), + **kwargs, + ) ) else: columns.append(Column(f.name, sa_type, **kwargs)) @@ -1257,7 +1464,12 @@ async def has_perm(self, perm_name: str, session) -> bool: table_name, action = parts attr = f"can_{action}" - if attr not in ("can_view", "can_create", "can_edit", "can_delete"): + if attr not in ( + "can_view", + "can_create", + "can_edit", + "can_delete", + ): return False role_ids = self.role_ids @@ -1279,7 +1491,10 @@ async def has_perm(self, perm_name: str, session) -> bool: result = await session.execute( select(Permission) - .join(UserPermission, UserPermission.permission_id == Permission.id) + .join( + UserPermission, + UserPermission.permission_id == Permission.id, + ) .where(UserPermission.user_id == self.id) ) for perm in result.scalars(): diff --git a/tests/test_schema_materialization.py b/tests/test_schema_materialization.py index 3ee353d..1a5dcd7 100644 --- a/tests/test_schema_materialization.py +++ b/tests/test_schema_materialization.py @@ -516,8 +516,10 @@ def verify_password(self, password): def test_custom_auth_model_skips_builtin_user_tables(self): """When a custom auth_model is supplied, the built-in ``admin_users`` - and ``admin_user_roles`` tables are reported as 'skip' so that - ``create_all`` does not emit DDL for the default user schema. + table is reported as 'skip' so that ``create_all`` does not emit + DDL for the default user schema. ``admin_user_roles`` is kept + and its ``user_id`` foreign key is retargeted to the custom + auth_model's table at create time. """ from fastapi_admin_kit.admin.core import Admin @@ -535,7 +537,7 @@ def verify_password(self, password): admin = Admin(auth_model=CustomUser) skipped = admin._builtin_user_tables_to_skip() assert "admin_users" in skipped - assert "admin_user_roles" in skipped + assert "admin_user_roles" not in skipped def test_default_user_tables_kept_when_no_auth_model(self): """Default installation (auth_model is None) must NOT skip the @@ -559,9 +561,11 @@ def test_default_user_tables_kept_when_auth_model_is_builtin(self): @pytest.mark.asyncio async def test_create_tables_skips_builtin_user_when_custom_auth_model(self, monkeypatch): """``admin.create_tables()`` must NOT create the built-in - ``admin_users`` / ``admin_user_roles`` tables when a custom - ``auth_model`` is supplied (this is what callers in a FastAPI - ``lifespan`` rely on).""" + ``admin_users`` table when a custom ``auth_model`` is supplied + (this is what callers in a FastAPI ``lifespan`` rely on). + ``admin_user_roles`` IS created with its ``user_id`` FK + retargeted to the custom auth_model's table. + """ from sqlalchemy import Column, Integer, String from sqlalchemy.orm import DeclarativeBase @@ -588,7 +592,7 @@ def verify_password(self, password: str) -> bool: admin = Admin(auth_model=CustomUser) # Capture the ``tables=`` argument handed to ``create_all`` so we can - # assert the built-in user tables are excluded. We mock the engine + # assert the built-in user table is excluded. We mock the engine # and metadata by passing a fake backend. captured: dict = {} @@ -616,7 +620,11 @@ async def has_tables(self, engine, names): ) emitted = {t.name for t in captured["tables"]} assert "admin_users" not in emitted - assert "admin_user_roles" not in emitted + # admin_user_roles IS emitted (with retargeted FK) + assert "admin_user_roles" in emitted + # The cloned auth_model table is excluded from the create list + # (project's own metadata owns the real table). + assert "users" not in emitted # And nothing from the expected skip leaked in. expected_skip = set(admin._builtin_user_tables_to_skip()) assert emitted.isdisjoint(expected_skip) From 48382e73e74dc1f81a0b839cfbbca922fd4d13e1 Mon Sep 17 00:00:00 2001 From: borhanst Date: Tue, 1 Sep 2026 18:20:32 +0600 Subject: [PATCH 03/14] feat: enhance user representation in admin templates and CLI output --- fastapi_admin_kit/auth/mixins.py | 18 ++++++++++++++++++ fastapi_admin_kit/cli/user.py | 2 +- .../templates/admin/base_detail.html | 5 +++-- .../templates/admin/base_form.html | 5 +++-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/fastapi_admin_kit/auth/mixins.py b/fastapi_admin_kit/auth/mixins.py index 8836ec7..f44e28e 100644 --- a/fastapi_admin_kit/auth/mixins.py +++ b/fastapi_admin_kit/auth/mixins.py @@ -63,6 +63,24 @@ def role_ids(self) -> list[int]: return [] return [r.id for r in roles] + def __str__(self) -> str: + """Return a human-readable identifier for the user. + + Prefers ``email``, falls back to ``username``, then to ``id``. + Prevents admin templates from dumping raw SQLAlchemy ``__repr__`` + output when the inheriting model does not define ``__str__``. + """ + email = getattr(self, "email", None) + if email: + return str(email) + username = getattr(self, "username", None) + if username: + return str(username) + return str(getattr(self, "id", "")) + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self.__str__()}>" + def verify_password(self, password: str) -> bool: """Check if plaintext password matches the stored hash.""" from fastapi_admin_kit.auth.password import password_manager diff --git a/fastapi_admin_kit/cli/user.py b/fastapi_admin_kit/cli/user.py index 6aa49fb..62d372f 100644 --- a/fastapi_admin_kit/cli/user.py +++ b/fastapi_admin_kit/cli/user.py @@ -229,7 +229,7 @@ def _flag(val_col: str | None) -> str: for user in users: name_val = getattr(user, name_col, "") if name_col else "" print( - f"{user.id:<6} {user.email:<30} {str(name_val):<{name_width}} " + f"{str(user.id)[:6]:<6} {user.email:<30} {str(name_val):<{name_width}} " f"{_flag(superuser_col):<10} {_flag(active_col):<8}" ) diff --git a/fastapi_admin_kit/templates/admin/base_detail.html b/fastapi_admin_kit/templates/admin/base_detail.html index 458ec8b..3f4cc8b 100644 --- a/fastapi_admin_kit/templates/admin/base_detail.html +++ b/fastapi_admin_kit/templates/admin/base_detail.html @@ -17,7 +17,7 @@ / {{ registered.verbose_name_plural }} / -{{ obj }} +{{ obj.email | default(obj.username, true) | default(obj.id, true) }} {% endblock %} {% block content %} @@ -32,7 +32,8 @@

View {{ registered.verbose_name }}

#{{ obj.id }} - {% if obj.__str__() %} — {{ obj.__str__() }}{% endif %} + {% set _obj_label = obj.email | default(obj.username, true) %} + {% if _obj_label %} — {{ _obj_label }}{% endif %}

diff --git a/fastapi_admin_kit/templates/admin/base_form.html b/fastapi_admin_kit/templates/admin/base_form.html index 5a5b3f8..320e2ab 100644 --- a/fastapi_admin_kit/templates/admin/base_form.html +++ b/fastapi_admin_kit/templates/admin/base_form.html @@ -22,7 +22,7 @@ / {{ registered.verbose_name_plural }} / -{% if obj %}Edit {{ obj }}{% else %}Create{% endif %} +{% if obj %}{% set _obj_str = obj.email | default(obj.username, true) | default(obj.id, true) %}Edit {{ _obj_str }}{% else %}Create{% endif %} {% endblock %} {% block content %} @@ -40,7 +40,8 @@

{% if obj %}

#{{ obj.id }} - {% if obj.__str__() %} — {{ obj.__str__() }}{% endif %} + {% set _obj_label = obj.email | default(obj.username, true) %} + {% if _obj_label %} — {{ _obj_label }}{% endif %}

{% endif %}

From 65abe95463b8aae05e36b975a020ada0b674a589 Mon Sep 17 00:00:00 2001 From: borhanst Date: Tue, 1 Sep 2026 18:41:13 +0600 Subject: [PATCH 04/14] fix: enhance user model resolution and query backend handling in notifications --- fastapi_admin_kit/notifications/dispatcher.py | 100 +++++++++++++----- 1 file changed, 71 insertions(+), 29 deletions(-) diff --git a/fastapi_admin_kit/notifications/dispatcher.py b/fastapi_admin_kit/notifications/dispatcher.py index a301bb7..d2f10f8 100644 --- a/fastapi_admin_kit/notifications/dispatcher.py +++ b/fastapi_admin_kit/notifications/dispatcher.py @@ -18,14 +18,49 @@ import inspect from typing import Any -from sqlalchemy import String, cast, select - from fastapi_admin_kit.db import get_db_session -from fastapi_admin_kit.migrations.models import NotificationPreference, User +from fastapi_admin_kit.migrations.models import NotificationPreference from fastapi_admin_kit.notifications.config import ChangeNotificationConfig from fastapi_admin_kit.notifications.service import NotificationService +def _resolve_user_model(request: Any) -> Any: + """Return the user model that owns ``is_superuser``/``is_active``. + + Prefers the project's ``auth_model`` when one is configured (so joins and + recipient lookups route to the project's user table). Falls back to the + built-in admin ``User`` when no custom auth_model is set. + """ + builtin_user: Any | None + try: + from fastapi_admin_kit.migrations.models import User as BuiltinUser + except Exception: + builtin_user = None + else: + builtin_user = BuiltinUser + + admin = getattr(request.app.state, "admin", None) + auth_model = getattr(admin, "auth_model", None) if admin is not None else None + if auth_model is not None and auth_model is not builtin_user: + return auth_model + return builtin_user + + +def _get_query_backend(request: Any) -> Any: + """Return the configured :class:`QueryBackend` from app state. + + Falls back to importing the SQLAlchemy adapter when the app has not yet + wired the backend (e.g. very early startup paths or test harnesses that + bypass ``Admin()``). + """ + qb = getattr(request.app.state, "admin_query_adapter", None) + if qb is not None: + return qb + from fastapi_admin_kit.backends import SqlAlchemyQueryAdapter + + return SqlAlchemyQueryAdapter() + + async def dispatch_model_change( request: Any, *, @@ -81,14 +116,18 @@ async def dispatch_model_change( # - regular admins only if they have enabled NotificationPreference rows if recipients is None: session = get_db_session(request) + user_model = _resolve_user_model(request) + if user_model is None: + return + qb = _get_query_backend(request) recipients = [] - superusers = await session.all( - select(User).where( - User.is_superuser.is_(True), - User.is_active.is_(True), - ) + superusers_q = qb.where( + qb.select(user_model), + user_model.is_superuser.is_(True), + user_model.is_active.is_(True), ) + superusers = await session.all(superusers_q) for user in superusers: recipients.append( { @@ -99,30 +138,33 @@ async def dispatch_model_change( } ) - pref_user_ids = set( - await session.all( - select(NotificationPreference.user_id).where( - NotificationPreference.enabled.is_(True) - ) - ) + pref_q = qb.where( + qb.select(NotificationPreference), + NotificationPreference.enabled.is_(True), ) + pref_user_ids = { + str(getattr(row, "user_id", None)) + for row in await session.all(pref_q) + if getattr(row, "user_id", None) is not None + } if pref_user_ids: - regular = await session.all( - select(User).where( - User.is_superuser.is_(False), - User.is_active.is_(True), - cast(User.id, String).in_(pref_user_ids), - ) + # Pull active non-superusers and filter by pref in Python so the + # query stays backend-agnostic (no cast()/String literal needed). + regular_q = qb.where( + qb.select(user_model), + user_model.is_superuser.is_(False), + user_model.is_active.is_(True), ) - for user in regular: - recipients.append( - { - "id": getattr(user, "id", None), - "email": getattr(user, "email", None), - "phone": getattr(user, "phone", None), - "channels": cfg.default_channels, - } - ) + for user in await session.all(regular_q): + if str(getattr(user, "id", "")) in pref_user_ids: + recipients.append( + { + "id": getattr(user, "id", None), + "email": getattr(user, "email", None), + "phone": getattr(user, "phone", None), + "channels": cfg.default_channels, + } + ) # Never notify the actor about their own change. if cfg.exclude_actor and actor_id is not None: From 290d72e169c4bf1bb356ad9bc3f79f062103492f Mon Sep 17 00:00:00 2001 From: borhanst Date: Tue, 1 Sep 2026 19:45:27 +0600 Subject: [PATCH 05/14] feat: enhance SelectWidget to support enum classes for better value handling --- fastapi_admin_kit/widgets/inputs.py | 55 ++++++++- fastapi_admin_kit/widgets/resolver.py | 3 +- tests/test_widget_resolver.py | 23 +++- tests/test_widgets.py | 162 ++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 5 deletions(-) diff --git a/fastapi_admin_kit/widgets/inputs.py b/fastapi_admin_kit/widgets/inputs.py index 48f74c3..0012802 100644 --- a/fastapi_admin_kit/widgets/inputs.py +++ b/fastapi_admin_kit/widgets/inputs.py @@ -2,6 +2,7 @@ from __future__ import annotations +import enum import json from datetime import date, datetime from typing import Any @@ -88,19 +89,67 @@ def validate(self, value: Any, field: FieldMeta) -> list[str]: class SelectWidget(Widget): macro_name = "select" - def __init__(self, choices: list[tuple[str, str]] | None = None): + def __init__( + self, + choices: list[tuple[str, str]] | None = None, + enum_class: type | None = None, + ): self.choices = choices or [] + self.enum_class = enum_class + + @staticmethod + def _enum_stored_value(value: enum.Enum) -> Any: + """Return the representation SA actually stores in the DB for this enum. + + SQLAlchemy's ``Enum`` type stores ``.value`` for ``str``-Enum subclasses + (including ``StrEnum``) and ``.name`` for plain ``Enum``. We mirror that + here so the value rendered into the ``