diff --git a/CHANGELOG.md b/CHANGELOG.md index 295c981..abdaa20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +- feat: add Django-style `SimpleFilter` base class — class-configured filters, pass the class itself in `list_filter` +- feat: list filters configured in `list_filter` are documented as query params on the JSON API list endpoint in the OpenAPI/Swagger schema (lookups limited to the field type's supported lookups) +- change: filter query params dropped the `filter_` prefix — they now use the bare field name and lookup (e.g. `name__icontains=wid`) in both the admin UI and the JSON API +- feat: enum columns map to real enum types in the API create/update schemas so Swagger shows them as dropdowns; models with file/image fields expose create/update as multipart forms with file pickers, saved through the same storage backend as the HTML form +- fix: `SelectWidget` validation accepted only member names, rejecting the stored values of str-enum members + ## [0.6.0] - 2026-09-03 - feat: improve form rendering — WYSIWYG and array input widgets ([#59](https://github.com/borhanst/fastapi-admin-kit/pull/59)) diff --git a/docs/guide/custom-auth-model.md b/docs/guide/custom-auth-model.md index aec6b6c..3ab1ee3 100644 --- a/docs/guide/custom-auth-model.md +++ b/docs/guide/custom-auth-model.md @@ -20,7 +20,7 @@ import uuid from typing import Optional from fastapi import FastAPI -from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String from sqlalchemy.orm import DeclarativeBase, relationship from sqlalchemy.types import Uuid @@ -37,11 +37,15 @@ class User(AuthModelMixin, Base): id = Column(Uuid, primary_key=True, default=uuid.uuid4) email = Column(String(255), unique=True, nullable=False, index=True) - hashed_password = Column(String(255), nullable=False) name = Column(String(255), nullable=True) - # AuthModelMixin provides the rest of the protocol surface - # (is_active, is_superuser, role_ids, verify_password, etc.). + # Required: AuthModelMixin is behavior-only — add these 4 fields + # with your ORM (SQLAlchemy shown; SQLModel/Tortoise declare the + # same 4 names with their own Field types). + password = Column(String(255), nullable=False) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + last_login = Column(DateTime(timezone=True), nullable=True) app = FastAPI() @@ -124,16 +128,19 @@ Your `auth_model` must satisfy `AdminUserProtocol` (validated at |-----------|------|-------| | `id` | any | The PK type — `int`, `UUID`, etc. | | `email` | `str` | Used as the login identifier | -| `is_active` | `bool` | Inactive users cannot log in | -| `is_superuser` | `bool` | Bypasses all RBAC checks | -| `hashed_password` | `str` | bcrypt / argon2 hash | -| `role_ids` | `list[int]` | Property that returns role IDs | -| `roles` | relationship | M2M to `Role` model | -| `verify_password(plain)` | method | Returns `bool` | - -`fastapi_admin_kit.auth.mixins.AuthModelMixin` provides all of the -above for SQLAlchemy declarative models — inherit from it to get -`is_active`, `is_superuser`, `role_ids`, and password helpers for free. +| `password` | `str` | Hashed password (bcrypt / argon2), `String(255)`, `nullable=False` recommended | +| `is_active` | `bool` | Inactive users cannot log in (`default=True` recommended) | +| `is_superuser` | `bool` | Bypasses all RBAC checks (`default=False` recommended) | +| `last_login` | `datetime \| None` | Tz-aware, nullable | +| `role_ids` | `list[int]` | Property that returns role IDs (provided by mixin) | +| `roles` | relationship | M2M to `Role` model (optional if you only need `role_ids`) | +| `verify_password(plain)` | method | Returns `bool` (provided by mixin) | + +`fastapi_admin_kit.auth.mixins.AuthModelMixin` is behavior-only — it +provides `role_ids`, `verify_password`, `hash_password`, `has_perm`, etc. +You must add the 4 fields (`password`, `is_active`, `is_superuser`, +`last_login`) with your ORM on your model. The same 4 names apply to +SQLAlchemy, SQLModel, Tortoise, and other Python ORMs. ## Troubleshooting diff --git a/docs/guide/filters.md b/docs/guide/filters.md index d0370de..54e0b95 100644 --- a/docs/guide/filters.md +++ b/docs/guide/filters.md @@ -123,36 +123,71 @@ class ProductAdmin(ModelAdmin): list_filter = ["name", IntegerFilter("price", label="Price")] ``` +### SimpleFilter + +Django `SimpleListFilter` style: declare `parameter_name` + `title` as class +attributes and pass the *class itself* in `list_filter` — no constructor args, +no instance: + +```python +from fastapi_admin_kit.filters import SimpleFilter + +class InStockFilter(SimpleFilter): + parameter_name = "in_stock" + title = "Stock Status" + field_type = "boolean" + + def apply(self, query_adapter, query, model, value): + raw = value.get("exact") if isinstance(value, dict) else value + if raw and raw.lower() in ("1", "true"): + return model.stock > 0 + if raw: + return model.stock <= 0 + return None + + def get_choices(self, session=None): + return [("", "All"), ("1", "In stock"), ("0", "Out of stock")] + +@admin.register(Product) +class ProductAdmin(ModelAdmin): + list_filter = [InStockFilter] # class itself, no instantiation +``` + ## Query Parameter Lookups Filters are applied as query parameters in both the admin UI list view and the -JSON API. Lookups follow the `django-filter` convention (`filter___`): +JSON API. Lookups follow the field-name convention (`__`): ``` -filter_name=value exact match -filter_name__icontains=term case-insensitive contains -filter_name__startswith=Jo starts with -filter_name__endswith=hn ends with -filter_price__gt=100 greater than -filter_price__gte=100 greater than or equal -filter_price__lt=50 less than -filter_price__lte=200 less than or equal -filter_price__range=10,200 range (inclusive) -filter_id__in=1,2,3 in list -filter_is_active=1 boolean (1/true/yes, 0/false/no) -filter_category=1 relation exact match +name=value exact match +name__icontains=term case-insensitive contains +name__startswith=Jo starts with +name__endswith=hn ends with +price__gt=100 greater than +price__gte=100 greater than or equal +price__lt=50 less than +price__lte=200 less than or equal +price__range=10,200 range (inclusive) +id__in=1,2,3 in list +is_active=1 boolean (1/true/yes, 0/false/no) +category=1 relation exact match ``` Examples: ``` -/admin/products/?filter_name__icontains=phone&filter_price__gte=100 -/api/products/?filter_category=2&filter_price__range=10,200 +/admin/products/?name__icontains=phone&price__gte=100 +/api/products/?category=2&price__range=10,200 ``` Multiple filters are AND'd together. Range values are comma-separated pairs; `in` values are comma-separated lists. +On the JSON API, the list endpoint documents every configured filter as +optional query parameters in the OpenAPI/Swagger schema — add a filter to +`list_filter` and it (plus the lookups its field type supports) shows up in +`/openapi.json` automatically. + ## Per-Filter UI Options Customize individual filter UI: diff --git a/docs/guide/json-api.md b/docs/guide/json-api.md index 7ac44d0..bf87d1a 100644 --- a/docs/guide/json-api.md +++ b/docs/guide/json-api.md @@ -168,6 +168,20 @@ Auto-generated JSON schemas are available for each model: curl http://localhost:8000/admin/api/schema/products/ ``` +### Enum fields render as dropdowns + +`Enum` columns map to real enum types in the create/update schemas, so +Swagger shows them as dropdowns (both Python-enum classes and plain +`Enum("a", "b")` columns). + +### File/image fields use multipart forms + +When a model has file/image upload fields (`LargeBinary` columns or +`formfield_overrides` with `FileUploadWidget`/`ImageUploadWidget`), the +create/update endpoints accept `multipart/form-data` instead of JSON so files +can be picked directly in Swagger. Uploads go through the same storage +backend as the admin HTML form. + ## Next Steps - [Authentication & RBAC](auth-rbac.md) — Set up permissions diff --git a/example/example.py b/example/example.py index fa4d468..921b339 100644 --- a/example/example.py +++ b/example/example.py @@ -42,8 +42,9 @@ from fastapi_admin_kit.inline import StackedInline, TabularInline from fastapi_admin_kit.models import Base as AdminBase from fastapi_admin_kit.pagination.cursor import CursorPagination +from fastapi_admin_kit.storage.local import LocalStorageBackend from fastapi_admin_kit.types import TabConfig, TableSection -from fastapi_admin_kit.widgets.inputs import ArrayWidget, WysiwygWidget +from fastapi_admin_kit.widgets.inputs import ArrayWidget, ImageUploadWidget, WysiwygWidget # ============================================================================ # SQLAlchemy Models @@ -83,6 +84,7 @@ class Product(Base): description = Column(Text, nullable=True) price = Column(Float, nullable=False) stock = Column(Integer, default=0) + image = Column(String(500), nullable=True) # uploaded product photo (path) category_id = Column(Integer, ForeignKey("categories.id"), nullable=True) is_active = Column(Boolean, default=True) sort_order = Column(Integer, default=0) @@ -105,7 +107,12 @@ class User(AuthModelMixin, Base): id = Column(Integer, primary_key=True) email = Column(String(255), nullable=False, unique=True) full_name = Column(String(255), nullable=True) + # Required auth fields — AuthModelMixin is behavior-only, declare + # these 4 with your ORM. + password = Column(String(255), nullable=False) is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + last_login = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) # Relationships @@ -290,7 +297,7 @@ class CategoryAdmin(ModelAdmin): TabConfig(title="All", url="/admin/categories/"), TabConfig( title="Active", - url="/admin/categories/?filter_created_at__gte=2025-01-01", + url="/admin/categories/?created_at__gte=2025-01-01", ), ] @@ -327,6 +334,7 @@ class ProductAdmin(ModelAdmin): "category", "price", "stock", + "image", "is_active", ] readonly_fields = ["created_at", "updated_at"] @@ -349,8 +357,8 @@ class ProductAdmin(ModelAdmin): # Tabs list_tabs = [ TabConfig(title="All Products", url="/admin/products/"), - TabConfig(title="Active", url="/admin/products/?filter_is_active=1"), - TabConfig(title="Out of Stock", url="/admin/products/?filter_stock__lte=0"), + TabConfig(title="Active", url="/admin/products/?is_active=1"), + TabConfig(title="Out of Stock", url="/admin/products/?stock__lte=0"), ] # Sortable @@ -380,6 +388,7 @@ def status(self, obj): formfield_overrides = { "description": WysiwygWidget(), # "tags": ArrayWidget(), + "image": ImageUploadWidget(max_size_mb=5), # product photo upload } @action( @@ -440,7 +449,7 @@ class UserAdmin(ModelAdmin): # Tabs list_tabs = [ TabConfig(title="All Users", url="/admin/users/"), - TabConfig(title="Active", url="/admin/users/?filter_is_active=1"), + TabConfig(title="Active", url="/admin/users/?is_active=1"), ] # Form UX @@ -496,8 +505,8 @@ class OrderAdmin(ModelAdmin): # Tabs list_tabs = [ TabConfig(title="All Orders", url="/admin/orders/"), - TabConfig(title="Pending", url="/admin/orders/?filter_status=pending"), - TabConfig(title="Completed", url="/admin/orders/?filter_status=completed"), + TabConfig(title="Pending", url="/admin/orders/?status=pending"), + TabConfig(title="Completed", url="/admin/orders/?status=completed"), ] # Expandable sections @@ -814,6 +823,9 @@ async def lifespan(app: FastAPI): per_page_default=25, secret_key=SECRET_KEY, auth_backend=BuiltinAuthBackend(), + # File/image uploads (used by the Product image field + the JSON API + # multipart endpoints); served from /uploads by admin.setup(). + storage=LocalStorageBackend(upload_dir=str(EXAMPLE_DIR / "uploads")), sidebar_bottom_links=[ {"label": "Settings", "url": "/admin/users/", "icon": "cog-6-tooth"}, {"label": "Help", "url": "https://docs.example.com"}, diff --git a/fastapi_admin_kit/__init__.py b/fastapi_admin_kit/__init__.py index 8c92c32..e96e43b 100644 --- a/fastapi_admin_kit/__init__.py +++ b/fastapi_admin_kit/__init__.py @@ -134,4 +134,4 @@ "configure_notifications", "notifications_router", ] -__version__ = "0.6.1" +__version__ = "0.6.2" diff --git a/fastapi_admin_kit/api/auth.py b/fastapi_admin_kit/api/auth.py index 35f6dc7..59d909b 100644 --- a/fastapi_admin_kit/api/auth.py +++ b/fastapi_admin_kit/api/auth.py @@ -225,6 +225,64 @@ def decode_access_token(token: str, secret_key: str) -> dict[str, Any] | None: return None +def parse_jwt_subject(sub: Any) -> int | str | None: + """Normalize a JWT ``sub`` claim to a DB-usable user id. + + The ``AdminUserProtocol`` allows any PK type (``int``, ``str``, ``UUID``), + and :func:`create_access_token` stores ``str(user.id)``. Earlier code did + ``int(sub)`` and dropped every non-integer subject, so a valid token + minted for a UUID/str-PK user could be issued but never validated + (``Account not found or inactive`` on every subsequent call). + + Returns ``int`` for digit strings (keeps integer-PK queries exact), + the stripped string otherwise, and ``None`` for missing/empty subjects. + """ + if sub is None: + return None + if isinstance(sub, bool): + return None + if isinstance(sub, int): + return sub + # UUID objects (or any non-str scalar) — stringify; get_user() coerces back. + try: + text = str(sub).strip() + except Exception: + return None + if not text: + return None + try: + return int(text) + except (TypeError, ValueError): + return text + + +def extract_bearer_token(auth_header: str | None) -> str | None: + """Extract the raw JWT from an ``Authorization`` header value. + + Tolerates the two most common Swagger copy-paste mistakes: + + - pasting ``Bearer `` into the ``BearerAuth`` value field, which + Swagger then sends as ``Bearer Bearer ``; + - surrounding whitespace/quotes from copying a JSON response body. + """ + if not auth_header or not auth_header.startswith("Bearer "): + return None + token = auth_header[7:].strip() + if not token: + return None + # Tolerate a duplicated scheme prefix (case-insensitive). + if len(token) > 7 and token[:7].lower() == "bearer ": + token = token[7:].strip() + if not token: + return None + # Tolerate surrounding quotes from JSON copy-paste. + if len(token) >= 2 and ( + (token[0] == '"' and token[-1] == '"') or (token[0] == "'" and token[-1] == "'") + ): + token = token[1:-1].strip() + return token or None + + def _hash_token(token: str) -> str: """SHA256 hash of a token for storage.""" return hashlib.sha256(token.encode()).hexdigest() @@ -455,22 +513,18 @@ async def get_current_user_info( immediately. """ auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): + token = extract_bearer_token(auth_header) + if token is None: raise HTTPException(status_code=401, detail="Missing or invalid Authorization header.") - token = auth_header[7:] secret_key = _get_secret_key(request) payload = decode_access_token(token, secret_key) if payload is None: raise HTTPException(status_code=401, detail="Invalid or expired token.") - sub = payload.get("sub") - if sub is None: + user_id = parse_jwt_subject(payload.get("sub")) + if user_id is None: raise HTTPException(status_code=401, detail="Invalid or expired token.") - try: - user_id: int | str = int(sub) - except (TypeError, ValueError): - raise HTTPException(status_code=401, detail="Invalid or expired token.") from None # Resolve through the AuthBackend seam: honours BYO user models and # returns None for deleted/deactivated accounts. diff --git a/fastapi_admin_kit/api/crud.py b/fastapi_admin_kit/api/crud.py index 9722778..40ad44f 100644 --- a/fastapi_admin_kit/api/crud.py +++ b/fastapi_admin_kit/api/crud.py @@ -7,12 +7,26 @@ from typing import Annotated, Any -from fastapi import APIRouter, Body, Depends, HTTPException, Request +from fastapi import ( + APIRouter, + Body, + Depends, + File, + Form, + HTTPException, + Query, + Request, + UploadFile, +) from fastapi.responses import Response from pydantic import BaseModel from fastapi_admin_kit.api.deps import require_api_permission -from fastapi_admin_kit.api.schema_generator import get_or_build_schemas +from fastapi_admin_kit.api.schema_generator import ( + _file_field_names, + get_or_build_schemas, + has_file_fields, +) from fastapi_admin_kit.views.class_views import ( CreateView, DeleteView, @@ -52,7 +66,7 @@ def _export_endpoint(registered: Any) -> str | None: def build_api_router(registry: Any) -> APIRouter: """Build the CRUD API router for all registered models.""" - router = APIRouter(tags=["api-crud"]) + router = APIRouter() for registered in registry.all(): # Respect skip_auto_routes (set for internal/built-in tables and any @@ -69,6 +83,14 @@ def build_api_router(registry: Any) -> APIRouter: return router +def _api_tags(registered: Any) -> list[str]: + """Return OpenAPI tags: admin.tag if set, else verbose_name.""" + tag = getattr(registered.admin, "tag", None) + if tag: + return [tag] + return [registered.verbose_name] + + def build_api_router_for_model(registered: Any) -> APIRouter: """Build a standalone CRUD router for a single model. @@ -78,7 +100,7 @@ def build_api_router_for_model(registered: Any) -> APIRouter: """ router = APIRouter( prefix=f"/{registered.table_name}", - tags=["api-crud", registered.verbose_name], + tags=_api_tags(registered), ) _register_model_routes(router, registered) return router @@ -149,6 +171,138 @@ async def wrapped(request: Request, item_id: Any) -> Any: return wrapped +def _wrap_multipart_handler( + handler: Any, + payload_schema: type[BaseModel], + registered: Any, + *, + include_item_id: bool = False, +) -> Any: + """Multipart variant of :func:`_wrap_body_handler` for file/image models. + + Declares one ``File()`` param per file column and a ``Form()`` param for + every other write-schema field, so Swagger switches to a multipart form + with file pickers. Non-file values arrive as raw strings (exactly like the + HTML form) and are coerced by ``JSONBodyParser``'s widget pipeline; + ``None`` values are dropped to keep partial-update semantics. + """ + from inspect import Parameter, Signature + + file_names = _file_field_names(registered) + + params = [ + Parameter("request", Parameter.POSITIONAL_OR_KEYWORD, annotation=Request), + ] + if include_item_id: + params.append(Parameter("item_id", Parameter.POSITIONAL_OR_KEYWORD, annotation=Any)) + for name in payload_schema.model_fields: + if name in file_names: + param_type = Annotated[UploadFile | None, File()] + else: + param_type = Annotated[str | None, Form()] + # All params optional here — required fields are enforced by the + # shared widget/validator pipeline, exactly like the HTML form. + params.append(Parameter(name, Parameter.KEYWORD_ONLY, default=None, annotation=param_type)) + + if include_item_id: + + async def wrapped(request: Request, **kwargs: Any) -> Any: + item_id = kwargs.pop("item_id", None) + request.state._api_payload = {k: v for k, v in kwargs.items() if v is not None} + return await handler(request, item_id=item_id) + + else: + + async def wrapped(request: Request, **kwargs: Any) -> Any: + request.state._api_payload = {k: v for k, v in kwargs.items() if v is not None} + return await handler(request) + + wrapped.__signature__ = Signature(params) + wrapped.__name__ = getattr(handler, "__name__", "api_response") + wrapped.__doc__ = getattr(handler, "__doc__", None) + return wrapped + + +# Lookups each filter field_type supports — mirrors the apply() methods in +# filters/base.py. Exact uses the bare ``filter_`` param. +LOOKUPS_BY_TYPE: dict[str, tuple[str, ...]] = { + "text": ("exact", "icontains", "startswith", "endswith"), + "boolean": ("exact",), + "enum": ("exact", "in"), + "relation": ("exact",), + "integer": ("exact", "gt", "gte", "lt", "lte", "range", "in"), + "numeric": ("exact", "gt", "gte", "lt", "lte", "range", "in"), + "date": ("exact", "gt", "gte", "lt", "lte", "range", "in", "from", "to"), + "datetime": ("exact", "gt", "gte", "lt", "lte", "range", "in", "from", "to"), + "time": ("exact", "gt", "gte", "lt", "lte", "range", "in"), +} + + +def _lookups_for_field_type(field_type: str) -> tuple[str, ...]: + """Return the lookup names a filter field_type supports (from base.apply()).""" + return LOOKUPS_BY_TYPE.get(field_type, ("exact",)) + + +def _wrap_list_handler(handler: Any, registered: Any) -> Any: + """Document configured list filters as query params in the OpenAPI schema. + + Filtering already works — the query provider reads ``filter_*`` from + ``request.query_params``. This wrapper only declares those params so + Swagger lists them: it mirrors the handler's own signature (pagination + stays documented) and appends one optional string param per filter field + for each lookup its type supports. Only the handler's original params are + forwarded; the ``filter_*`` values reach the provider through the raw + query string. + """ + import inspect + from inspect import Parameter, Signature + + from fastapi_admin_kit.filters import Filter, FilterRegistry, SimpleFilter + from fastapi_admin_kit.filters.lookups import LOOKUP_SUFFIXES + + suffix = dict(LOOKUP_SUFFIXES) + try: + auto = FilterRegistry().auto_generate(registered.model, registered.columns) + except Exception: + auto = {} + + filter_fields: list[tuple[str, str]] = [] # (field_name, field_type) + for item in registered.admin.list_filter or []: + if isinstance(item, str): + f = auto.get(item) + filter_fields.append((item, getattr(f, "field_type", "text") if f else "text")) + elif isinstance(item, Filter): + filter_fields.append((item.field_name, item.field_type)) + elif isinstance(item, type) and issubclass(item, SimpleFilter): + f = item() + filter_fields.append((f.field_name, f.field_type)) + else: + continue + + sig = inspect.signature(handler) + params = list(sig.parameters.values()) + for name, field_type in filter_fields: + for lookup in _lookups_for_field_type(field_type): + qname = f"{name}{suffix[lookup]}" + params.append( + Parameter( + qname, + Parameter.KEYWORD_ONLY, + default=Query(None, description=f"Filter on '{name}' ({lookup})"), + ) + ) + + handler_params = {p.name for p in sig.parameters.values() if p.name != "request"} + + async def wrapped(request: Request, **kwargs: Any) -> Any: + return await handler(request, **{k: v for k, v in kwargs.items() if k in handler_params}) + + wrapped.__signature__ = Signature(params) + wrapped.__name__ = getattr(handler, "__name__", "api_response") + wrapped.__doc__ = getattr(handler, "__doc__", None) + return wrapped + + def _register_model_routes(router: APIRouter, registered: Any) -> None: """Register CRUD routes for a single model using view classes.""" table_name = registered.table_name @@ -167,22 +321,38 @@ def _register_model_routes(router: APIRouter, registered: Any) -> None: create_schema = schemas["create"] update_schema = schemas["update"] - # Add routes with both "api-crud" and model verbose_name tags + list_handler = list_v.api_response if hasattr(list_v, "api_response") else list_v + + # Models with file/image columns use multipart form bodies (file pickers + # in Swagger); everything else keeps the JSON body. + use_multipart = has_file_fields(registered) + + def _body_wrapper( + handler: Any, payload_schema: type[BaseModel], *, include_item_id: bool = False + ) -> Any: + if use_multipart: + return _wrap_multipart_handler( + handler, payload_schema, registered, include_item_id=include_item_id + ) + return _wrap_body_handler(handler, payload_schema, include_item_id=include_item_id) + + # Add routes with admin tag(s) or verbose_name fallback + api_tags = _api_tags(registered) router.add_api_route( "", - list_v.api_response if hasattr(list_v, "api_response") else list_v, + _wrap_list_handler(list_handler, registered), methods=["GET"], response_model=list_response_schema, - tags=["api-crud", registered.verbose_name], + tags=api_tags, dependencies=[Depends(require_api_permission(table_name, "view"))], ) router.add_api_route( "", - _wrap_body_handler(create_v.api_response, create_schema), + _body_wrapper(create_v.api_response, create_schema), methods=["POST"], response_model=response_schema, status_code=201, - tags=["api-crud", registered.verbose_name], + tags=api_tags, dependencies=[Depends(require_api_permission(table_name, "create"))], ) router.add_api_route( @@ -190,23 +360,23 @@ def _register_model_routes(router: APIRouter, registered: Any) -> None: _wrap_item_handler(edit_v.api_response), methods=["GET"], response_model=response_schema, - tags=["api-crud", registered.verbose_name], + tags=api_tags, dependencies=[Depends(require_api_permission(table_name, "view"))], ) router.add_api_route( "/{item_id}", - _wrap_body_handler(edit_v.api_response, update_schema, include_item_id=True), + _body_wrapper(edit_v.api_response, update_schema, include_item_id=True), methods=["PUT"], response_model=response_schema, - tags=["api-crud", registered.verbose_name], + tags=api_tags, dependencies=[Depends(require_api_permission(table_name, "edit"))], ) router.add_api_route( "/{item_id}", - _wrap_body_handler(edit_v.api_response, update_schema, include_item_id=True), + _body_wrapper(edit_v.api_response, update_schema, include_item_id=True), methods=["PATCH"], response_model=response_schema, - tags=["api-crud", registered.verbose_name], + tags=api_tags, dependencies=[Depends(require_api_permission(table_name, "edit"))], ) router.add_api_route( @@ -214,6 +384,6 @@ def _register_model_routes(router: APIRouter, registered: Any) -> None: _wrap_item_handler(delete_v.api_response, returns_response=True), methods=["DELETE"], status_code=204, - tags=["api-crud", registered.verbose_name], + tags=api_tags, dependencies=[Depends(require_api_permission(table_name, "delete"))], ) diff --git a/fastapi_admin_kit/api/deps.py b/fastapi_admin_kit/api/deps.py index c60ab06..90d91b8 100644 --- a/fastapi_admin_kit/api/deps.py +++ b/fastapi_admin_kit/api/deps.py @@ -20,6 +20,8 @@ from fastapi_admin_kit.api.auth import ( _get_secret_key, decode_access_token, + extract_bearer_token, + parse_jwt_subject, token_predates_password_change, ) @@ -34,11 +36,10 @@ async def get_api_current_user(request: Request) -> dict[str, Any]: if cached is not None: return cached - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): + token = extract_bearer_token(request.headers.get("Authorization", "")) + if token is None: raise HTTPException(status_code=401, detail="Missing or invalid Authorization header.") - token = auth_header[7:] secret_key = _get_secret_key(request) payload = decode_access_token(token, secret_key) if payload is None: @@ -51,13 +52,12 @@ async def _resolve_live_user(request: Request, user: dict[str, Any]) -> Any | No Returns ``None`` when the account was deleted or deactivated, so stale tokens cannot keep working after the account is removed. + + The subject may be an ``int`` (built-in User) or a ``str``/``UUID`` + (custom ``auth_model``) — it is passed through unchanged so BYO PKs work. """ - sub = user.get("sub") - if sub is None: - return None - try: - user_id: int | str = int(sub) - except (TypeError, ValueError): + user_id = parse_jwt_subject(user.get("sub")) + if user_id is None: return None from fastapi_admin_kit.auth.identity import resolve_user diff --git a/fastapi_admin_kit/api/middleware.py b/fastapi_admin_kit/api/middleware.py index b3d2376..2bf66aa 100644 --- a/fastapi_admin_kit/api/middleware.py +++ b/fastapi_admin_kit/api/middleware.py @@ -97,6 +97,8 @@ async def dispatch( from fastapi_admin_kit.api.auth import ( _get_secret_key, decode_access_token, + extract_bearer_token, + parse_jwt_subject, token_predates_password_change, ) from fastapi_admin_kit.auth.identity import resolve_user @@ -106,17 +108,15 @@ async def dispatch( except Exception: # noqa: BLE001 — app misconfiguration surfaces below return await call_next(request) - payload = decode_access_token(auth_header[7:], secret_key) + token = extract_bearer_token(auth_header) + if token is None: + return _unauthorized("Missing or invalid Authorization header.") + + payload = decode_access_token(token, secret_key) if payload is None: return _unauthorized("Invalid or expired token.") - sub = payload.get("sub") - user_id: int | str | None = None - if sub is not None: - try: - user_id = int(sub) - except (TypeError, ValueError): - user_id = None + user_id = parse_jwt_subject(payload.get("sub")) user = await resolve_user(request, user_id) if user_id is not None else None if user is None: diff --git a/fastapi_admin_kit/api/schema_generator.py b/fastapi_admin_kit/api/schema_generator.py index 19c7e45..63aecd2 100644 --- a/fastapi_admin_kit/api/schema_generator.py +++ b/fastapi_admin_kit/api/schema_generator.py @@ -14,7 +14,6 @@ def _sa_type_to_python(sa_type: Any) -> type: Boolean, Date, DateTime, - Enum, Float, Integer, LargeBinary, @@ -42,15 +41,40 @@ def _sa_type_to_python(sa_type: Any) -> type: return datetime.time if type_cls in (LargeBinary,): return bytes - if type_cls is Enum: - return str return Any +def _enum_python_type(sa_type: Any, field_name: str) -> type: + """Return the Python type for a SQLAlchemy ``Enum`` column. + + Uses the underlying Python enum class when the column declares one; + otherwise builds a dynamic ``Enum`` with one member per stored value so + Swagger renders the field as a dropdown either way. Falls back to ``str`` + when no value list is known. + """ + import enum + + enum_class = getattr(sa_type, "enum_class", None) + if isinstance(enum_class, type) and issubclass(enum_class, enum.Enum): + return enum_class + values = list(getattr(sa_type, "enums", None) or []) + if values: + # ponytail: member names are generated (values may not be valid + # identifiers); two models sharing a field_name with different values + # would share one OpenAPI component — scope by model if it ever bites. + members = {f"MEMBER_{i}": v for i, v in enumerate(values)} + return enum.Enum(f"{field_name}Enum", members) + return str + + def _get_column_python_type(col: Any) -> type: - """Get the Python type for a column, handling ForeignKey.""" + """Get the Python type for a column, handling ForeignKey and Enum.""" if col.foreign_keys: return int + from sqlalchemy import Enum + + if isinstance(col.type, Enum): + return _enum_python_type(col.type, col.name) return _sa_type_to_python(col.type) @@ -216,6 +240,38 @@ def build_list_response_schema(registered: Any) -> type[BaseModel]: ) +def _file_field_names(registered: Any) -> set[str]: + """Names of file/image upload fields, honoring fields/exclude config. + + Detection is widget-based (same rule as the HTML form's ``has_file_field``): + a field is a file field when its widget is a file-upload widget, whether + by column type (``LargeBinary``) or a ``formfield_overrides`` entry on a + string column (the usual path-storage pattern). + """ + from fastapi_admin_kit.views.file_handler import FILE_WIDGET_TYPES + + names: set[str] = set() + admin = registered.admin + for col in registered.columns: + if col.name == "id": + continue + if admin.fields is not None and col.name not in admin.fields: + continue + if admin.exclude and col.name in admin.exclude: + continue + try: + if isinstance(registered.get_widget(col.name), FILE_WIDGET_TYPES): + names.add(col.name) + except Exception: + continue + return names + + +def has_file_fields(registered: Any) -> bool: + """True when the model's write schemas include file/image upload columns.""" + return bool(_file_field_names(registered)) + + def get_or_build_schemas(registered: Any) -> dict[str, type[BaseModel]]: """Get or generate and cache schemas for a registered model.""" if hasattr(registered, "_schemas") and registered._schemas is not None: diff --git a/fastapi_admin_kit/auth/backend.py b/fastapi_admin_kit/auth/backend.py index 707c77f..d1b7afd 100644 --- a/fastapi_admin_kit/auth/backend.py +++ b/fastapi_admin_kit/auth/backend.py @@ -29,7 +29,7 @@ async def authenticate( ... @abstractmethod - async def get_user(self, user_id: int | str, session: Any) -> AdminUserProtocol | None: + async def get_user(self, user_id: Any, session: Any) -> AdminUserProtocol | None: """Load user by PK. Return ``None`` if not found or inactive.""" ... @@ -173,20 +173,60 @@ async def authenticate( return None return user - async def get_user( + def _candidate_user_ids(self, user_id: Any) -> list[Any]: + """Return DB lookup candidates for *user_id* covering int/str/UUID PKs. + + JWT ``sub`` arrives as ``str`` (``create_access_token`` stores + ``str(user.id)``), while the DB PK may be an ``int``, a ``str``, or a + ``UUID`` object. Strict ``==`` comparison (SQLAlchemy or the in-memory + backend) fails on type mismatch, so we try the original value plus + its int/UUID reinterpretations in order. + """ + if user_id is None or isinstance(user_id, bool): + return [] + candidates: list[Any] = [user_id] + seen = {(type(user_id).__name__, str(user_id))} + + def _add(value: Any) -> None: + key = (type(value).__name__, str(value)) + if key not in seen: + seen.add(key) + candidates.append(value) + + if isinstance(user_id, int): + _add(str(user_id)) + return candidates + if isinstance(user_id, str): + text = user_id.strip() + if text != user_id: + _add(text) + user_id = text + try: + _add(int(text)) + except (TypeError, ValueError): + pass + try: + import uuid as _uuid + + _add(_uuid.UUID(text)) + except Exception: + pass + return candidates + # UUID objects (or any other scalar PK): also try their string form. + try: + _add(str(user_id)) + except Exception: + pass + return candidates + + async def _lookup_user_by_id( self, - user_id: int | str, + query_backend_resolved: Any, session: Any, - query_adapter: Any | None = None, - query_backend: Any | None = None, - **kwargs: Any, - ) -> AdminUserProtocol | None: - qb = query_adapter if query_adapter is not None else query_backend - if qb is None: - qb = kwargs.get("query_adapter") or kwargs.get("query_backend") - query_backend_resolved = self._resolve_query_backend(qb) - session = self._resolve_session(session) - model = self._get_model() + model: type, + candidate: Any, + ) -> Any | None: + """Run a single PK + is_active lookup for *candidate*.""" query = query_backend_resolved.select(model) is_active_col = getattr(model, "is_active", None) id_col = getattr(model, "id", None) @@ -195,13 +235,12 @@ async def get_user( if is_active_col is not None: query = query_backend_resolved.where( query, - id_col == user_id, + id_col == candidate, is_active_col == True, # noqa: E712 ) else: - query = query_backend_resolved.where(query, id_col == user_id) + query = query_backend_resolved.where(query, id_col == candidate) - # Eagerly load roles if the model has a roles relationship if hasattr(model, "roles"): try: if query_backend_resolved.__class__.__name__ == "SqlAlchemyQueryAdapter": @@ -214,6 +253,28 @@ async def get_user( result = session.scalar_one_or_none(query) return await result if hasattr(result, "__await__") else result + async def get_user( + self, + user_id: Any, + session: Any, + query_adapter: Any | None = None, + query_backend: Any | None = None, + **kwargs: Any, + ) -> AdminUserProtocol | None: + qb = query_adapter if query_adapter is not None else query_backend + if qb is None: + qb = kwargs.get("query_adapter") or kwargs.get("query_backend") + query_backend_resolved = self._resolve_query_backend(qb) + session = self._resolve_session(session) + model = self._get_model() + if getattr(model, "id", None) is None: + return None + for candidate in self._candidate_user_ids(user_id): + user = await self._lookup_user_by_id(query_backend_resolved, session, model, candidate) + if user is not None: + return user + return None + async def on_logout(self, user_id: int | str | None = None) -> None: """No-op for built-in backend.""" return None diff --git a/fastapi_admin_kit/auth/identity.py b/fastapi_admin_kit/auth/identity.py index aae562b..9e2be7c 100644 --- a/fastapi_admin_kit/auth/identity.py +++ b/fastapi_admin_kit/auth/identity.py @@ -52,7 +52,7 @@ def _get_db_session(request: Request) -> AsyncSession | None: return None -async def resolve_user(request: Request, user_id: int | str | None) -> AdminUserProtocol | None: +async def resolve_user(request: Request, user_id: Any | None) -> AdminUserProtocol | None: """Resolve *user_id* to an active user and cache it on the request. Idempotent for a given request: if ``request.state.admin_user`` is already @@ -62,7 +62,7 @@ async def resolve_user(request: Request, user_id: int | str | None) -> AdminUser Always honours the configured ``AuthBackend.get_user`` seam, so BYO user models are supported on every transport (cookie *and* JWT), not just the - built-in one. + built-in one. ``user_id`` may be an ``int``, ``str``, or ``UUID``. """ cached = getattr(request.state, "admin_user", None) if cached is not None: @@ -174,24 +174,24 @@ async def get_current_user_from_bearer( honours the ``AuthBackend.get_user`` seam just like the cookie path. """ # Imported lazily to avoid a circular import at module load time. - from fastapi_admin_kit.api.auth import _get_secret_key, decode_access_token + from fastapi_admin_kit.api.auth import ( + _get_secret_key, + decode_access_token, + extract_bearer_token, + parse_jwt_subject, + ) - auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): + token = extract_bearer_token(request.headers.get("Authorization", "")) + if token is None: return None - token = auth_header[len("Bearer ") :] secret_key = _get_secret_key(request) payload = decode_access_token(token, secret_key) if payload is None: return None - sub = payload.get("sub") - if sub is None: - return None - try: - user_id: int | str = int(sub) # type: ignore[assignment] - except (TypeError, ValueError): + user_id = parse_jwt_subject(payload.get("sub")) + if user_id is None: return None user = await resolve_user(request, user_id) diff --git a/fastapi_admin_kit/auth/mixins.py b/fastapi_admin_kit/auth/mixins.py index f44e28e..6ee5c2c 100644 --- a/fastapi_admin_kit/auth/mixins.py +++ b/fastapi_admin_kit/auth/mixins.py @@ -4,25 +4,44 @@ from typing import TYPE_CHECKING, ClassVar -from sqlalchemy import Boolean, Column, DateTime, String - from fastapi_admin_kit.backends import as_session_backend if TYPE_CHECKING: + from datetime import datetime + from sqlalchemy.ext.asyncio import AsyncSession + # Editor-only hints for the 4 ORM-mapped fields the host model must + # declare with its own ORM (SQLAlchemy Column, SQLModel Field, + # Tortoise field, ...). Declared here only for type checkers — + # there are NO runtime attributes for these names on the mixin, + # so subclasses never trigger field-shadowing warnings. + password: str | None + is_active: bool + is_superuser: bool + last_login: datetime | None + class AuthModelMixin: - """Mixin for custom user models to work with admin's built-in RBAC. + """ORM-agnostic behavior mixin for custom user models. + + The mixin provides **behavior only** — no ORM columns/fields. + You must add these 4 fields with your ORM on your auth model:: - Provides: password, is_active, is_superuser, last_login columns, - role_ids property, verify_password() and hash_password() methods. + password — hashed password (str, e.g. String(255), NOT NULL) + is_active — bool, recommended default True + is_superuser — bool, recommended default False + last_login — datetime with timezone, nullable (recommended) + + Defaults/nullability beyond presence are per-app; the admin validator + only checks that the attributes exist (plus ``verify_password``). + Works with SQLAlchemy, SQLModel, and other Python ORMs — each model + declares the 4 fields with its own ``Column``/``Field`` type. Usage:: from fastapi_admin_kit.auth.mixins import AuthModelMixin - from fastapi_admin_kit.auth.models import admin_user_roles, Role - from sqlalchemy.orm import relationship + from sqlalchemy import Boolean, Column, DateTime, String class MyUser(AuthModelMixin, Base): __tablename__ = "my_users" @@ -31,16 +50,18 @@ class MyUser(AuthModelMixin, Base): username = Column(String(255), unique=True) email = Column(String(255), unique=True) + # Required: declare these 4 with your ORM + password = Column(String(255), nullable=False) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + last_login = Column(DateTime(timezone=True), nullable=True) + # Define roles relationship yourself (FK must match your table) roles = relationship( "Role", secondary=admin_user_roles, back_populates="users" ) The mixin provides: - - ``password`` column (String 255) — stores the hashed password - - ``is_active`` column (Boolean, default True) - - ``is_superuser`` column (Boolean, default False) - - ``last_login`` column (DateTime with timezone, nullable) - ``role_ids`` property → ``list[int]`` (reads from ``self.roles``) - ``verify_password(password)`` → bool - ``hash_password(password)`` → str (classmethod) @@ -50,11 +71,6 @@ class MyUser(AuthModelMixin, Base): _hasher: ClassVar[type | None] = None - password = Column(String(255)) - is_active = Column(Boolean, default=True) - is_superuser = Column(Boolean, default=False) - last_login = Column(DateTime(timezone=True), nullable=True) - @property def role_ids(self) -> list[int]: """Return list of role IDs from the ``roles`` relationship.""" diff --git a/fastapi_admin_kit/auth/protocol.py b/fastapi_admin_kit/auth/protocol.py index 79efdec..70ea444 100644 --- a/fastapi_admin_kit/auth/protocol.py +++ b/fastapi_admin_kit/auth/protocol.py @@ -52,12 +52,20 @@ class AdminUserProtocol(Protocol): Any user model passed as ``auth_model=`` must satisfy this interface. The admin framework only reads these attributes from the user object. + + ``AuthModelMixin`` is behavior-only — the host model must declare the + 4 data fields (``password``, ``is_active``, ``is_superuser``, + ``last_login``) with its own ORM (SQLAlchemy ``Column``, SQLModel + ``Field``, Tortoise field, ...). Either a ``roles`` relationship or + the ``role_ids`` property satisfies role lookups. """ id: Any # primary key (int, str, UUID, etc.) email: str # used for audit log denormalization - is_active: bool # inactive users are refused login - is_superuser: bool # bypasses all permission checks if True + password: str | None # hashed password; declare with your ORM + is_active: bool # inactive users are refused login; declare with your ORM + is_superuser: bool # bypasses all permission checks if True; declare with your ORM + last_login: Any | None # tz-aware datetime or None; declare with your ORM # Many-to-many roles — the admin reads this to look up permissions. # Must be an iterable of role objects, each with an `id` attribute diff --git a/fastapi_admin_kit/backends/memory.py b/fastapi_admin_kit/backends/memory.py index 7028ac1..4f5a0ac 100644 --- a/fastapi_admin_kit/backends/memory.py +++ b/fastapi_admin_kit/backends/memory.py @@ -428,6 +428,21 @@ def get_relationship(self, model: type, name: str) -> Any: def get_relationship_local_columns(self, model: type, name: str) -> list[str]: return [] + def get_relationship_meta(self, model: type, name: str) -> RelationMeta | None: + schema: Schema = getattr(model, "__schema__", None) + if schema is None: + return None + r = schema.get_relation(name) + if r is None: + return None + return RelationMeta( + name=r.name, + direction=r.type.upper(), + target_model=None, + back_populates=r.back_populates, + secondary=r.through, + ) + def get_column_type_name(self, model: type, field_name: str) -> str | None: schema: Schema = getattr(model, "__schema__", None) if schema is None: @@ -442,6 +457,46 @@ def get_pk_columns(self, model: type) -> list[Any]: pk = _pk_field_name(model) return [pk] if pk else [] + _DISPLAY_CANDIDATES: tuple[str, ...] = ("name", "title", "email", "username", "label") + + def get_display_label(self, obj: Any) -> str | None: + """Short label for *obj* (custom ``__str__`` or display field).""" + if type(obj).__str__ is not object.__str__: + try: + text = str(obj) + except Exception: + text = "" + if text and "=" not in text: + return text + schema: Schema = getattr(type(obj), "__schema__", None) + available = {f.name for f in schema.fields} if schema is not None else None + for attr in self._DISPLAY_CANDIDATES: + if available is not None and attr not in available: + continue + try: + label = getattr(obj, attr, None) + except Exception: + label = None + if label is not None and str(label).strip(): + return str(label) + return None + + def get_default_search_fields(self, model: type) -> list[str]: + """Default ``search_fields`` for *model* (existing candidates first).""" + schema: Schema = getattr(model, "__schema__", None) + if schema is None: + return [] + by_name = {f.name: f for f in schema.fields} + found = [a for a in self._DISPLAY_CANDIDATES if a in by_name] + if found: + return found + for f in schema.fields: + if any( + hint in str(f.type).lower() for hint in ("string", "str", "text", "char", "email") + ): + return [f.name] + return [] + # --------------------------------------------------------------------------- # Audit backend diff --git a/fastapi_admin_kit/backends/protocols.py b/fastapi_admin_kit/backends/protocols.py index 573ec45..e09d7c7 100644 --- a/fastapi_admin_kit/backends/protocols.py +++ b/fastapi_admin_kit/backends/protocols.py @@ -54,6 +54,10 @@ def get_relationship(self, model: type, name: str) -> Any: """Return a single relationship descriptor by name, or None.""" ... + def get_relationship_meta(self, model: type, name: str) -> RelationMeta | None: + """Return ORM-agnostic metadata for a single relationship, or None.""" + ... + def get_relationship_local_columns(self, model: type, name: str) -> list[str]: """Return the local column key(s) for a relationship. @@ -75,6 +79,30 @@ def get_pk_columns(self, model: type) -> list[Any]: """Return the primary key column(s) for a model.""" ... + def get_display_label(self, obj: Any) -> str | None: + """Return a short human-readable label for *obj*, or None. + + Used for relation-picker options, list/detail relation values, and + anywhere a related object must render as ``label`` instead of full + row data. Backends must ignore framework-default ``__str__`` + implementations that dump every field (e.g. SQLModel/Pydantic + ``BaseModel.__str__``) and prefer a custom ``__str__``, falling + back to ``name`` / ``title`` / ``email`` / ``username`` attributes. + Callers apply their own final fallback (``#id``, ``ClassName:pk``). + """ + ... + + def get_default_search_fields(self, model: type) -> list[str]: + """Return default searchable field names for *model*. + + Used when ``ModelAdmin.search_fields`` is unset. Backends return + the ``name`` / ``title`` / ``email`` / ``username`` columns that + actually exist on the model, falling back to the first text-like + column so models without those fields (e.g. feedback/comment + tables) are still searchable instead of silently unfiltered. + """ + ... + @runtime_checkable class SessionBackend(Protocol): diff --git a/fastapi_admin_kit/backends/sqlalchemy.py b/fastapi_admin_kit/backends/sqlalchemy.py index f5a0d79..809a656 100644 --- a/fastapi_admin_kit/backends/sqlalchemy.py +++ b/fastapi_admin_kit/backends/sqlalchemy.py @@ -323,6 +323,23 @@ def get_relationship_local_columns(self, model: type, name: str) -> list[str]: return [] return [c.key for c in rel.local_columns] + def get_relationship_meta(self, model: type, name: str) -> RelationMeta | None: + """Return ORM-agnostic metadata for a single relationship, or None.""" + mapper = sa_inspect(model) + rel = mapper.relationships.get(name) + if rel is None: + return None + from fastapi_admin_kit.inspection.types import RelationMeta + + return RelationMeta( + name=rel.key, + direction=rel.direction.name, + target_model=rel.mapper.class_, + uselist=rel.uselist, + back_populates=rel.back_populates, + secondary=rel.secondary, + ) + def get_column_type_name(self, model: type, field_name: str) -> str | None: """Return the SQLAlchemy type class name for a column, or None.""" mapper = sa_inspect(model) @@ -347,6 +364,92 @@ def get_pk_columns(self, model: type) -> list[Any]: mapper = sa_inspect(model) return list(mapper.primary_key) + # Display candidates in priority order. Only attributes that exist as + # real columns are considered, so proxies/deferred loaders on missing + # attributes are never triggered. + _DISPLAY_CANDIDATES: tuple[str, ...] = ("name", "title", "email", "username", "label") + + def get_display_label(self, obj: Any) -> str | None: + """Short label for *obj* (custom ``__str__`` or display column).""" + if self._has_custom_str(obj): + try: + text = str(obj) + except Exception: + text = "" + # Guard against __str__ implementations that still dump fields + # (e.g. "id=1 name='x'"): prefer a clean column value instead. + if text and "=" not in text: + return text + try: + columns, _ = self.inspect_model(type(obj)) + available = {c.name for c in columns} + except Exception: + # Not a mapped model (plain object, memory record, ...): + # fall back to a plain attribute probe. + available = None + for attr in self._DISPLAY_CANDIDATES: + if available is not None and attr not in available: + continue + try: + label = getattr(obj, attr, None) + except Exception: + label = None + if label is not None and str(label).strip(): + return str(label) + if self._has_custom_str(obj): + try: + return str(obj) + except Exception: + return None + return None + + def get_default_search_fields(self, model: type) -> list[str]: + """Default ``search_fields`` for *model* (existing candidates first).""" + try: + columns, _ = self.inspect_model(model) + except Exception: + return [] + by_name = {c.name: c for c in columns} + found = [a for a in self._DISPLAY_CANDIDATES if a in by_name] + if found: + return found + for col in columns: + # ColumnMeta.type may be a type *class* (SQLModel-resolved) or + # an instance (plain SQLAlchemy) — handle both. + col_type = col.type + if col_type is None: + continue + type_name = ( + col_type.__name__ if isinstance(col_type, type) else type(col_type).__name__ + ).lower() + if any(hint in type_name for hint in ("string", "text", "char", "unicode")): + return [col.name] + return [] + + @staticmethod + def _has_custom_str(obj: Any) -> bool: + """True if the model defines a real custom ``__str__``. + + Framework-default ``__str__`` implementations that dump every + field (SQLModel/Pydantic ``BaseModel.__str__``) are explicitly + ignored so they never leak full row data into labels. + """ + str_fn = type(obj).__str__ + if str_fn is object.__str__: + return False + owner = getattr(str_fn, "__objclass__", None) + if owner is None: + # Plain function defined on a class in the MRO — find it. + for klass in type(obj).__mro__: + if "__str__" in klass.__dict__: + owner = klass + break + if owner is not None: + module = getattr(owner, "__module__", "") or "" + if module.split(".")[0] in ("sqlmodel", "pydantic"): + return False + return True + # -- internal helpers --------------------------------------------------- def _is_sqlmodel(self, model: type) -> bool: diff --git a/fastapi_admin_kit/config/auth.py b/fastapi_admin_kit/config/auth.py index f651e73..69cd256 100644 --- a/fastapi_admin_kit/config/auth.py +++ b/fastapi_admin_kit/config/auth.py @@ -83,27 +83,36 @@ def validate_auth_model(self) -> None: f"{', '.join(missing)}. Every auth model must have id and email." ) - # Required: is_active, is_superuser (can be provided by AuthModelMixin) + # Required: is_active, is_superuser, last_login. + # AuthModelMixin is behavior-only — add these 4 fields with your ORM. missing_flags = [] if not hasattr(model, "is_active"): missing_flags.append("is_active") if not hasattr(model, "is_superuser"): missing_flags.append("is_superuser") + if not hasattr(model, "last_login"): + missing_flags.append("last_login") if missing_flags: raise ConfigError( f"auth_model {model.__name__!r} is missing: {', '.join(missing_flags)}. " - f"Use AuthModelMixin or add these columns to your model." + f"Add these 4 fields with your ORM (see docs): " + f"password, is_active, is_superuser, last_login." ) - # Required: roles or role_ids (for RBAC) + # Required: roles or role_ids (for RBAC). + # AuthModelMixin provides a defensive role_ids property that returns + # [] when the model has no roles relationship (e.g. single-role + # enum models). Full RBAC requires a roles relationship. if not hasattr(model, "roles") and not hasattr(model, "role_ids"): raise ConfigError( f"auth_model {model.__name__!r} has no 'roles' relationship or " f"'role_ids' property. RBAC requires role lookups. " - f"Use AuthModelMixin or define a roles relationship on your model." + f"Add a roles relationship with your ORM on your model." ) - # Check password-related attributes for authentication + # Check password-related attributes for authentication. + # AuthModelMixin provides verify_password/hash_password behavior — + # add the password field itself with your ORM. missing_auth = [] if not hasattr(model, "password"): missing_auth.append("password") @@ -113,6 +122,7 @@ def validate_auth_model(self) -> None: raise ConfigError( f"auth_model {model.__name__!r} is missing password-related " f"attributes: {', '.join(missing_auth)}. " - f"Use AuthModelMixin or implement password (str) and " - f"verify_password(password) -> bool." + f"Add the password field with your ORM and inherit " + f"AuthModelMixin (or implement password (str) and " + f"verify_password(password) -> bool)." ) diff --git a/fastapi_admin_kit/export_import/csv.py b/fastapi_admin_kit/export_import/csv.py index 2624a9e..53704a9 100644 --- a/fastapi_admin_kit/export_import/csv.py +++ b/fastapi_admin_kit/export_import/csv.py @@ -98,12 +98,13 @@ def export_filtered( # Apply search filter if q: + from fastapi_admin_kit.inspection import get_default_search_fields from fastapi_admin_kit.search_utils import apply_search_filter - search_fields = getattr(self.admin, "search_fields", None) or [ - "name", - "title", - ] + search_fields = getattr(self.admin, "search_fields", None) or get_default_search_fields( + self.registered.model, + getattr(request.app.state, "admin_introspection_adapter", None), + ) from sqlalchemy import select queryset = apply_search_filter( diff --git a/fastapi_admin_kit/filters/__init__.py b/fastapi_admin_kit/filters/__init__.py index 8f578bc..a1f4d38 100644 --- a/fastapi_admin_kit/filters/__init__.py +++ b/fastapi_admin_kit/filters/__init__.py @@ -13,6 +13,7 @@ IntegerFilter, NumericFilter, RelationFilter, + SimpleFilter, TextFilter, TimeFilter, ) @@ -28,6 +29,7 @@ "EnumFilter", "IntegerFilter", "NumericFilter", + "SimpleFilter", "DateRangeFilter", "DatetimeRangeFilter", "TimeFilter", diff --git a/fastapi_admin_kit/filters/base.py b/fastapi_admin_kit/filters/base.py index 485fb1b..420ae53 100644 --- a/fastapi_admin_kit/filters/base.py +++ b/fastapi_admin_kit/filters/base.py @@ -81,6 +81,21 @@ def _coerce(value: str, converter: Callable[[str], Any]) -> Any | None: except (ValueError, TypeError): return None + @staticmethod + def _coerce_column_value(value: Any, col: Any) -> Any | None: + if not isinstance(value, str): + return value + try: + python_type = col.type.python_type + except (AttributeError, NotImplementedError): + return value + if python_type is str: + return value + try: + return python_type(value) + except (ValueError, TypeError, OverflowError): + return None + @staticmethod def _combine(conditions: list, query_adapter: Any = None) -> Any | None: """Combine zero or more conditions into a single AND clause.""" @@ -141,6 +156,24 @@ def _comparison_lookups( return conditions +class SimpleFilter(Filter): + """Class-configured filter (Django ``SimpleListFilter`` style). + + The author declares ``parameter_name`` and ``title`` as class attributes + and passes the *class itself* (not an instance) in ``list_filter``. + """ + + parameter_name: str = "" + title: str = "" + + def __init__(self, label: str = "") -> None: + if not self.parameter_name: + raise TypeError( + f"{type(self).__name__} requires a non-empty 'parameter_name' class attribute" + ) + super().__init__(self.parameter_name, label or self.title) + + class TextFilter(Filter): """Text filter — exact match plus icontains/startswith/endswith lookups.""" @@ -194,12 +227,21 @@ def __init__( label: str = "", resolved_column: str | None = None, choices: list[str] | None = None, + relationship_name: str | None = None, + target_model: Any = None, + target_pk: str | None = None, ) -> None: super().__init__(field_name, label) self.resolved_column = resolved_column self._choices = list(choices or []) + self.relationship_name = relationship_name + self.target_model = target_model + self.target_pk = target_pk def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: + if self.relationship_name: + return self._apply_membership(query_adapter, model, value) + col_name = self.resolved_column or self.field_name col = self._column(model, col_name) if col is None: @@ -209,18 +251,63 @@ def apply(self, query_adapter: Any, query: Any, model: Any, value: Any) -> Any: conditions: list = [] exact = value.get("exact", "") if exact: - conditions.append(col == exact) + converted = self._coerce_column_value(exact, col) + if converted is not None: + conditions.append(col == converted) raw_in = value.get("in") if raw_in: - items = _split_csv(raw_in) + items = [] + for item in _split_csv(raw_in): + converted = self._coerce_column_value(item, col) + if converted is not None: + items.append(converted) if items: conditions.append(col.in_(items)) return self._combine(conditions, query_adapter) if value: - return col == value + converted = self._coerce_column_value(value, col) + if converted is None: + return None + return col == converted return None + def _apply_membership(self, query_adapter: Any, model: Any, value: Any) -> Any: + """Filter by related-object membership (M2M / reverse ONETOMANY). + + Builds ``model.rel.any(target.pk == value)``. Unsupported backends + (no ``.any()`` on the relationship) return None and skip the filter. + """ + rel = self._column(model, self.relationship_name or self.field_name) + target_col = getattr(self.target_model, self.target_pk, None) if self.target_model else None + if rel is None or target_col is None: + return None + try: + conditions: list = [] + if isinstance(value, dict): + exact = value.get("exact", "") + if exact: + converted = self._coerce_column_value(exact, target_col) + if converted is not None: + conditions.append(rel.any(target_col == converted)) + raw_in = value.get("in") + if raw_in: + items = [] + for item in _split_csv(raw_in): + converted = self._coerce_column_value(item, target_col) + if converted is not None: + items.append(converted) + if items: + conditions.append(rel.any(target_col.in_(items))) + elif value: + converted = self._coerce_column_value(value, target_col) + if converted is None: + return None + return rel.any(target_col == converted) + return self._combine(conditions, query_adapter) + except Exception: + return None + def get_choices(self, session: Any = None) -> list[tuple[str, str]]: if not self._choices: return [("", "All")] diff --git a/fastapi_admin_kit/filters/lookups.py b/fastapi_admin_kit/filters/lookups.py index b418555..d04699a 100644 --- a/fastapi_admin_kit/filters/lookups.py +++ b/fastapi_admin_kit/filters/lookups.py @@ -1,18 +1,18 @@ """Django-style lookup parsing for filter query parameters. -The admin accepts filters as ``filter_`` query parameters with the -same conventions as ``django-filter``:: +The admin accepts filters as ```` query parameters with the same +conventions as ``django-filter``:: - ?filter_name=value exact match - ?filter_name__icontains=term case-insensitive contains - ?filter_name__startswith=Jo starts with - ?filter_name__endswith=hn ends with - ?filter_price__gt=100 greater than - ?filter_price__gte=100 greater than or equal - ?filter_price__lt=50 less than - ?filter_price__lte=200 less than or equal - ?filter_price__range=10,200 range (inclusive) - ?filter_id__in=1,2,3 in list + ?name=value exact match + ?name__icontains=term case-insensitive contains + ?name__startswith=Jo starts with + ?name__endswith=hn ends with + ?price__gt=100 greater than + ?price__gte=100 greater than or equal + ?price__lt=50 less than + ?price__lte=200 less than or equal + ?price__range=10,200 range (inclusive) + ?id__in=1,2,3 in list This module owns the (query params -> value) mapping so the HTML list views and the JSON API share one source of truth. @@ -65,7 +65,7 @@ def parse_filter_params( parts: dict[str, str] = {} active: dict[str, str] = {} for lookup, suffix in LOOKUP_SUFFIXES: - raw = query_params.get(f"filter_{field_name}{suffix}", "") + raw = query_params.get(f"{field_name}{suffix}", "") if raw: parts[lookup] = raw active[f"{field_name}{suffix}"] = raw diff --git a/fastapi_admin_kit/filters/registry.py b/fastapi_admin_kit/filters/registry.py index 5721483..d047f10 100644 --- a/fastapi_admin_kit/filters/registry.py +++ b/fastapi_admin_kit/filters/registry.py @@ -98,8 +98,7 @@ def auto_generate( continue if field_name in rel_names: - resolved_col = self._resolve_fk_column(model, field_name, introspection) - filters[field_name] = ChoiceFilter(field_name, resolved_column=resolved_col) + filters[field_name] = self._relationship_filter(model, field_name, introspection) continue type_name = self._get_type_name(model, field_name, introspection) @@ -133,8 +132,7 @@ def auto_generate( for rel_name in rel_names: if rel_name not in filters: - resolved_col = self._resolve_fk_column(model, rel_name, introspection) - filters[rel_name] = ChoiceFilter(rel_name, resolved_column=resolved_col) + filters[rel_name] = self._relationship_filter(model, rel_name, introspection) return filters @@ -142,6 +140,91 @@ def auto_generate( # Internal helpers — all go through IntrospectionBackend when available # ------------------------------------------------------------------ + @classmethod + def _relationship_filter( + cls, + model: Any, + rel_name: str, + introspection: Any | None, + ) -> ChoiceFilter: + """Build a filter for a relationship, discovered via the backend. + + MANYTOONE relationships filter the local FK column. M2M / ONETOMANY + relationships have no local FK — filtering uses related-object + membership (``rel.any(target.pk == value)``). Falls back to a plain + ``ChoiceFilter`` when the target cannot be resolved (e.g. memory + backend, where relation filters aren't supported). + """ + meta = cls._get_relationship_meta(model, rel_name, introspection) + direction = meta.direction if meta is not None else None + resolved_col = cls._resolve_fk_column(model, rel_name, introspection) + + if direction != "MANYTOONE": + target = meta.target_model if meta is not None else None + target_pk = ( + cls._resolve_pk_column(target, introspection) if target is not None else None + ) + if target is not None and target_pk is not None: + return ChoiceFilter( + rel_name, + relationship_name=rel_name, + target_model=target, + target_pk=target_pk, + ) + return ChoiceFilter(rel_name, resolved_column=resolved_col) + + @staticmethod + def _get_relationship_meta( + model: Any, + rel_name: str, + introspection: Any | None, + ) -> Any | None: + """Return ORM-agnostic relationship metadata, or None.""" + if introspection is not None: + try: + return introspection.get_relationship_meta(model, rel_name) + except Exception: + return None + try: + from sqlalchemy import inspect as sa_inspect + + from fastapi_admin_kit.inspection.types import RelationMeta + + rel = sa_inspect(model).relationships.get(rel_name) + if rel is None: + return None + return RelationMeta( + name=rel.key, + direction=rel.direction.name, + target_model=rel.mapper.class_, + uselist=rel.uselist, + back_populates=rel.back_populates, + secondary=rel.secondary, + ) + except Exception: + return None + + @staticmethod + def _resolve_pk_column(model: Any, introspection: Any | None) -> str | None: + """Return the single primary-key column name for *model*, or None.""" + if introspection is not None: + try: + cols = introspection.get_pk_columns(model) + if cols: + col = cols[0] + return getattr(col, "key", col) + except Exception: + pass + try: + from sqlalchemy import inspect as sa_inspect + + mapper = sa_inspect(model) + if mapper.primary_key: + return mapper.primary_key[0].key + except Exception: + pass + return None + @staticmethod def _get_type_name(model: Any, field_name: str, introspection: Any | None) -> str | None: """Return the ORM type class name for a column, or None.""" diff --git a/fastapi_admin_kit/inspection.py b/fastapi_admin_kit/inspection.py index 3a7cde3..77d80cb 100644 --- a/fastapi_admin_kit/inspection.py +++ b/fastapi_admin_kit/inspection.py @@ -104,12 +104,18 @@ def is_required(col: ColumnMeta) -> bool: def model_display_name(obj: Any) -> str: """Return a human-readable label for an ORM object. - Uses the model's ``__str__`` if it has a custom implementation. - Falls back to ``ClassName:pk`` when ``__str__`` is the default - ``object.__str__``. + Delegates to the introspection backend + (``IntrospectionBackend.get_display_label``) so display logic lives + behind the multi-ORM seam; falls back to ``ClassName:pk``. """ - if type(obj).__str__ is not object.__str__: - return str(obj) + from fastapi_admin_kit.backends.sqlalchemy import SqlAlchemyIntrospectionAdapter + + try: + label = SqlAlchemyIntrospectionAdapter().get_display_label(obj) + except Exception: + label = None + if label: + return label pk = getattr(obj, "id", None) return f"{type(obj).__name__}:{pk}" if pk is not None else type(obj).__name__ diff --git a/fastapi_admin_kit/inspection/__init__.py b/fastapi_admin_kit/inspection/__init__.py index 3041d6e..b98da28 100644 --- a/fastapi_admin_kit/inspection/__init__.py +++ b/fastapi_admin_kit/inspection/__init__.py @@ -109,17 +109,47 @@ def is_required(col: ColumnMeta) -> bool: ) +def get_display_label(obj: Any, introspection: Any | None = None) -> str | None: + """Best-effort short label for *obj* via the introspection backend. + + Delegates to ``IntrospectionBackend.get_display_label`` (SQLAlchemy by + default) so display logic lives behind the multi-ORM seam instead of + inline ``getattr`` chains. Returns None when no clean label exists — + callers apply their own final fallback (``#id``, ``ClassName:pk``). + """ + adapter = introspection if introspection is not None else _inspector + try: + return adapter.get_display_label(obj) + except Exception: + return None + + +def get_default_search_fields(model: type, introspection: Any | None = None) -> list[str]: + """Default ``search_fields`` for *model* via the introspection backend. + + Falls back to ``["name", "title", "email"]`` when the backend cannot + determine fields (e.g. third-party backends predating this method), + preserving historical behaviour. + """ + adapter = introspection if introspection is not None else _inspector + try: + fields = adapter.get_default_search_fields(model) + except Exception: + fields = [] + return list(fields) if fields else ["name", "title", "email"] + + def model_display_name(obj: Any) -> str: """Return a human-readable label for an ORM object. - Uses the model's ``__str__`` if it has a custom implementation. - Falls back to ``name``, ``title``, or ``ClassName:pk``. + Resolved through the introspection backend (custom ``__str__``, else + ``name`` / ``title`` / ``email`` / ``username``), falling back to + ``ClassName:pk`` — so related objects always render as a short label, + never full row data. """ - if type(obj).__str__ is not object.__str__: - return str(obj) - label = getattr(obj, "name", None) or getattr(obj, "title", None) - if label is not None: - return str(label) + label = get_display_label(obj) + if label: + return label pk = getattr(obj, "id", None) return f"{type(obj).__name__}:{pk}" if pk is not None else type(obj).__name__ diff --git a/fastapi_admin_kit/modeladmin.py b/fastapi_admin_kit/modeladmin.py index af81565..7cdc4cb 100644 --- a/fastapi_admin_kit/modeladmin.py +++ b/fastapi_admin_kit/modeladmin.py @@ -224,12 +224,15 @@ def get_nav_badge(self, request: Any = None) -> str | None: # Object display def __str__(self, obj: Any) -> str: - """How to display an object in dropdowns/links.""" - return str( - getattr(obj, "name", None) - or getattr(obj, "title", None) - or f"#{getattr(obj, 'id', '?')}" - ) + """How to display an object in dropdowns/links. + + Label resolution goes through the introspection backend + (custom ``__str__``, else ``name`` / ``title`` / ``email`` / + ``username``); falls back to ``#id``. + """ + from fastapi_admin_kit.inspection import get_display_label + + return str(get_display_label(obj) or f"#{getattr(obj, 'id', '?')}") def get_model(self) -> Any: return self.model diff --git a/fastapi_admin_kit/router.py b/fastapi_admin_kit/router.py index 98db39c..510c1a5 100644 --- a/fastapi_admin_kit/router.py +++ b/fastapi_admin_kit/router.py @@ -228,9 +228,13 @@ async def export_data( # Apply search/filter from query params q = request.query_params.get("q", "") if q: + from fastapi_admin_kit.inspection import get_default_search_fields from fastapi_admin_kit.search_utils import apply_search_filter - search_fields = getattr(admin, "search_fields", None) or ["name", "title"] + search_fields = getattr(admin, "search_fields", None) or get_default_search_fields( + registered.model, + getattr(request.app.state, "admin_introspection_adapter", None), + ) base = apply_search_filter(base, registered.model, search_fields, q) # Execute query @@ -857,11 +861,20 @@ async def autocomplete( """Search-as-you-type endpoint for relation pickers.""" from fastapi.responses import JSONResponse + from fastapi_admin_kit.inspection import ( + get_default_search_fields, + model_display_name, + ) + session = get_db_session(request) model = registered.model results = [] - search_fields = getattr(registered.admin, "search_fields", None) or ["name", "title"] + search_fields = getattr( + registered.admin, "search_fields", None + ) or get_default_search_fields( + model, getattr(request.app.state, "admin_introspection_adapter", None) + ) from sqlalchemy import select @@ -869,12 +882,10 @@ async def autocomplete( query = apply_search_filter(request, select(model), model, search_fields, q).limit(20) for obj in await session.all(query): - label = str( - getattr(obj, "name", None) - or getattr(obj, "title", None) - or f"#{getattr(obj, 'id', '?')}" - ) - results.append({"id": str(obj.id), "label": label}) + # Label via the introspection backend — never full row data. + label = model_display_name(obj) + # Standard option shape: label/value (+ id for back-compat). + results.append({"id": str(obj.id), "value": str(obj.id), "label": label}) return JSONResponse(content=results) diff --git a/fastapi_admin_kit/static/js/admin.js b/fastapi_admin_kit/static/js/admin.js index f22362d..3599373 100644 --- a/fastapi_admin_kit/static/js/admin.js +++ b/fastapi_admin_kit/static/js/admin.js @@ -69,6 +69,32 @@ document.addEventListener('alpine:init', () => { }, }); + /* ── Relation option helpers (label/value only, never full JSON) ─── */ + function _normOption(raw) { + if (raw == null) return { id: '', value: '', label: '' }; + if (typeof raw === 'string') return { id: raw, value: raw, label: raw }; + const value = raw.value ?? raw.id ?? ''; + const label = raw.label ?? raw.name ?? raw.title ?? String(value ?? ''); + return { ...raw, id: value, value: value, label: label }; + } + + function _normList(data) { + if (!Array.isArray(data)) return []; + return data.map(_normOption); + } + + function _optLabel(opt) { + if (opt == null) return ''; + if (typeof opt === 'string') return opt; + return opt.label ?? opt.name ?? opt.title ?? String(opt.value ?? opt.id ?? ''); + } + + function _optValue(opt) { + if (opt == null) return ''; + if (typeof opt === 'string') return opt; + return String(opt.value ?? opt.id ?? ''); + } + /* ── Relation Picker ─────────────────────────────────────────────── */ Alpine.data('relationPicker', (initialId, initialLabel, searchUrl, fixed = false) => ({ @@ -132,7 +158,7 @@ document.addEventListener('alpine:init', () => { try { const resp = await fetch(`${searchUrl}?q=${encodeURIComponent(this.searchQuery)}`); if (resp.ok) { - this.results = await resp.json(); + this.results = _normList(await resp.json()); if (this.fixed) this._setPosition(); } } catch (e) { @@ -142,8 +168,9 @@ document.addEventListener('alpine:init', () => { }, select(result) { - this.selectedId = result.id; - this.searchQuery = result.label; + const opt = _normOption(result); + this.selectedId = _optValue(opt); + this.searchQuery = _optLabel(opt); this.results = []; this.open = false; }, @@ -180,9 +207,9 @@ document.addEventListener('alpine:init', () => { try { this.selectedIds = JSON.parse(initialIds); } catch (e) { this.selectedIds = []; } } if (Array.isArray(initialItems)) { - this.selectedItems = initialItems; + this.selectedItems = _normList(initialItems); } else if (typeof initialItems === 'string' && initialItems) { - try { this.selectedItems = JSON.parse(initialItems); } catch (e) { this.selectedItems = []; } + try { this.selectedItems = _normList(JSON.parse(initialItems)); } catch (e) { this.selectedItems = []; } } if (this.selectedIds.length > 0 && this.selectedItems.length === 0) { this._loadSelected(); @@ -207,6 +234,9 @@ document.addEventListener('alpine:init', () => { return []; }, + optLabel(opt) { return _optLabel(opt); }, + optValue(opt) { return _optValue(opt); }, + async _loadSelected() { try { const ids = this._ensureArray(this.selectedIds); @@ -216,7 +246,7 @@ document.addEventListener('alpine:init', () => { } const resp = await fetch(`${searchUrl}?ids=${ids.join(',')}`); if (resp.ok) { - this.selectedItems = await resp.json(); + this.selectedItems = _normList(await resp.json()); } } catch (e) { console.error('Multi-relation load error:', e); @@ -231,10 +261,10 @@ document.addEventListener('alpine:init', () => { const url = q ? `${searchUrl}?q=${encodeURIComponent(q)}` : `${searchUrl}`; const resp = await fetch(url); if (resp.ok) { - const all = await resp.json(); + const all = _normList(await resp.json()); const ids = this._ensureArray(this.selectedIds); const idStrs = ids.map(String); - this.results = all.filter(r => !idStrs.includes(String(r.id))); + this.results = all.filter(r => !idStrs.includes(String(_optValue(r)))); } } catch (e) { console.error('Multi-relation search error:', e); @@ -243,11 +273,13 @@ document.addEventListener('alpine:init', () => { }, add(result) { + const opt = _normOption(result); + const val = _optValue(opt); const ids = this._ensureArray(this.selectedIds); const idStrs = ids.map(String); - if (!idStrs.includes(String(result.id))) { - this.selectedIds.push(result.id); - this.selectedItems.push(result); + if (!idStrs.includes(String(val))) { + this.selectedIds.push(val); + this.selectedItems.push(opt); } this.searchQuery = ''; this.results = []; @@ -268,11 +300,19 @@ document.addEventListener('alpine:init', () => { open: false, _debounce: null, + optLabel(opt) { return opt.label ?? opt.name ?? _optLabel(opt); }, + init() { if (initialPermData && Array.isArray(initialPermData)) { - this.selectedPerms = initialPermData.map(p => ({ - id: p.id, name: p.name, table_name: p.table_name - })); + this.selectedPerms = initialPermData.map(p => { + const o = _normOption(p); + return { + id: _optValue(o), value: _optValue(o), + label: p.label ?? p.name ?? _optLabel(o), + name: p.label ?? p.name ?? _optLabel(o), + table_name: p.table_name, + }; + }); } }, @@ -284,17 +324,23 @@ document.addEventListener('alpine:init', () => { const url = q ? `${searchUrl}?q=${encodeURIComponent(q)}` : searchUrl; const resp = await fetch(url); if (resp.ok) { - const all = await resp.json(); - const selected = new Set(this.selectedPerms.map(p => p.id)); - this.results = all.filter(r => !selected.has(r.id)); + const all = _normList(await resp.json()).map(o => ({ + ...o, + name: o.label ?? o.name, + })); + const selected = new Set(this.selectedPerms.map(p => String(p.id))); + this.results = all.filter(r => !selected.has(String(_optValue(r)))); } } catch (e) { console.error('Permission search error:', e); } }, 250); }, addPerm(perm) { - if (!this.selectedPerms.find(p => p.id === perm.id)) { - this.selectedPerms.push({ id: perm.id, name: perm.name, table_name: perm.table_name }); + const o = _normOption(perm); + const val = _optValue(o); + const label = perm.label ?? perm.name ?? _optLabel(o); + if (!this.selectedPerms.find(p => String(p.id) === String(val))) { + this.selectedPerms.push({ id: val, value: val, label: label, name: label, table_name: perm.table_name }); } this.searchQuery = ''; this.results = []; @@ -330,12 +376,18 @@ document.addEventListener('alpine:init', () => { } }, + optLabel(opt) { return opt.label ?? opt.name ?? _optLabel(opt); }, + optValue(opt) { return _optValue(opt); }, + async _loadSelected() { try { const ids = this.selectedIds.join(','); const resp = await fetch(`${searchUrl}?ids=${ids}`); if (resp.ok) { - this.selectedItems = await resp.json(); + this.selectedItems = _normList(await resp.json()).map(o => ({ + ...o, + label: o.label ?? o.name ?? _optLabel(o), + })); } } catch (e) { console.error('Permission load error:', e); @@ -350,9 +402,12 @@ document.addEventListener('alpine:init', () => { const url = q ? `${searchUrl}?q=${encodeURIComponent(q)}` : searchUrl; const resp = await fetch(url); if (resp.ok) { - const all = await resp.json(); + const all = _normList(await resp.json()).map(o => ({ + ...o, + label: o.label ?? o.name ?? _optLabel(o), + })); const idSet = new Set(this.selectedIds.map(String)); - this.results = all.filter(r => !idSet.has(String(r.id))); + this.results = all.filter(r => !idSet.has(String(_optValue(r)))); } } catch (e) { console.error('Permission search error:', e); @@ -361,9 +416,11 @@ document.addEventListener('alpine:init', () => { }, add(result) { - if (!this.selectedIds.includes(result.id)) { - this.selectedIds.push(result.id); - this.selectedItems.push(result); + const opt = _normOption(result); + const val = _optValue(opt); + if (!this.selectedIds.map(String).includes(String(val))) { + this.selectedIds.push(val); + this.selectedItems.push({ ...opt, label: opt.label ?? opt.name ?? _optLabel(opt) }); } this.searchQuery = ''; this.results = []; diff --git a/fastapi_admin_kit/templates/admin/base_list.html b/fastapi_admin_kit/templates/admin/base_list.html index 90f71fb..3be798b 100644 --- a/fastapi_admin_kit/templates/admin/base_list.html +++ b/fastapi_admin_kit/templates/admin/base_list.html @@ -177,7 +177,7 @@

Import Data

{# Filter drawer panel #} -