Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,40 @@ 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"

- name: Generate CHANGELOG.md from GitHub releases
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: |
if git diff --quiet CHANGELOG.md; then
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 }}
13 changes: 3 additions & 10 deletions example/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,11 +716,11 @@ async def seed_demo_data(session: AsyncSession) -> None:

user1 = User(
email="alice@example.com", full_name="Alice Johnson", is_active=True,
hashed_password=password_manager.hash("alice"),
password=password_manager.hash("alice"),
)
user2 = User(
email="bob@example.com", full_name="Bob Smith", is_active=True,
hashed_password=password_manager.hash("bob"),
password=password_manager.hash("bob"),
)
session.add_all([user1, user2])
await session.flush()
Expand Down Expand Up @@ -755,7 +755,7 @@ async def seed_admin_user(session: AsyncSession) -> None:
hashed = bcrypt.hashpw(b"admin", bcrypt.gensalt()).decode()
admin_user = User(
email="admin@example.com",
hashed_password=hashed,
password=hashed,
full_name="Admin",
is_superuser=True,
is_active=True,
Expand Down Expand Up @@ -824,13 +824,6 @@ async def lifespan(app: FastAPI):
{"label": "Settings", "url": "/admin/users/", "icon": "cog-6-tooth"},
{"label": "Help", "url": "https://docs.example.com"},
],
# Theme configuration
theme=ThemeConfig(
preset="paper",
primary_color="#6366F1",
show_grain_texture=False,
show_accent_line=True,
),
# UI component configuration
sidebar_style="compact",
table_style="striped",
Expand Down
2 changes: 1 addition & 1 deletion example/example_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,7 +835,7 @@ async def seed_admin(session: AsyncSession) -> None:
hashed = bcrypt.hashpw(b"admin", bcrypt.gensalt()).decode()
admin_user = User(
email="admin@example.com",
hashed_password=hashed,
password=hashed,
full_name="Admin",
is_superuser=True,
is_active=True,
Expand Down
2 changes: 1 addition & 1 deletion example/example_custom_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ async def lifespan(app: FastAPI):
session.add(
User(
email="admin@example.com",
hashed_password=hashed,
password=hashed,
full_name="Admin",
is_superuser=True,
is_active=True,
Expand Down
2 changes: 1 addition & 1 deletion example/example_sqlmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ async def lifespan(app: FastAPI):
hashed = bcrypt.hashpw(b"admin", bcrypt.gensalt()).decode()
admin_user = User(
email="admin@example.com",
hashed_password=hashed,
password=hashed,
full_name="Admin",
is_superuser=True,
is_active=True,
Expand Down
2 changes: 1 addition & 1 deletion fastapi_admin_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,4 @@
"configure_notifications",
"notifications_router",
]
__version__ = "0.5.0"
__version__ = "0.5.1"
15 changes: 15 additions & 0 deletions fastapi_admin_kit/admin/admin_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
82 changes: 81 additions & 1 deletion fastapi_admin_kit/admin/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,32 @@ def _attr(obj: Any, name: str) -> Any:
return getattr(obj, name, "")

self._jinja_env.env.filters["slugify"] = slugify

def file_url(path: Any) -> str:
"""Map a stored file path to its public URL for ``src``/``href``.

Storage keeps relative paths (``"documents/uuid.jpg"``) so that
``upload_dir / path`` and ``delete(path)`` work. Browsers need
an absolute public URL (``"/uploads/documents/uuid.jpg"``), so
templates must pipe stored values through this filter instead
of rendering them raw.
"""
if not path:
return ""
s = str(path)
if s.startswith(("http://", "https://", "data:", "blob:")):
return s
storage = getattr(getattr(self.config, "storage", None), "storage", None)
if storage is not None and hasattr(storage, "url"):
try:
return storage.url(s)
except Exception:
pass
storage_cfg = getattr(self.config, "storage", None)
base = (getattr(storage_cfg, "uploads_url", None) or "/uploads").rstrip("/")
return f"{base}/{s.lstrip('/')}"

self._jinja_env.env.filters["file_url"] = file_url
self._jinja_env.env.globals["attr"] = _attr
from fastapi_admin_kit.inspection import model_display_name

Expand Down Expand Up @@ -1481,6 +1507,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.
"""
Expand All @@ -1489,7 +1527,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
Expand Down Expand Up @@ -1522,6 +1560,27 @@ 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.
#
# Use ``self.database._backend`` (the backend that actually performs
# DDL in ``_create_tables`` below) rather than the composite
# ``self.backend.database`` so tests that swap
# ``admin.database._backend`` with a fake do not mutate the global
# SQLAlchemy mapper state. A backend without ``adapt_auth_model``
# (e.g. a test fake or memory backend) is treated as no-op.
database_backend = getattr(self.database, "_backend", 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(
Expand Down Expand Up @@ -1607,6 +1666,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
Expand All @@ -1625,12 +1696,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:
Expand Down
2 changes: 0 additions & 2 deletions fastapi_admin_kit/auth/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,6 @@ async def authenticate(
session = self._resolve_session(session)
model = self._get_model()
field = getattr(model, login_field, None)
print("model: ", model)
print("field: ", field)
if field is None:
field = getattr(model, "email", None)
if field is None:
Expand Down
18 changes: 18 additions & 0 deletions fastapi_admin_kit/auth/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions fastapi_admin_kit/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,8 @@ async def login_post(
login_field=login_field,
query_adapter=query_adapter,
)
print("login user: ", user)

except TypeError:
print("login user error: ", user)
# Custom backends that don't accept query_adapter
user = await auth_backend.authenticate(username, password, session, login_field=login_field)
if user is not None:
Expand Down
10 changes: 10 additions & 0 deletions fastapi_admin_kit/backends/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions fastapi_admin_kit/backends/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
...
Loading
Loading