diff --git a/.gitignore b/.gitignore index 1d2ca69..a6bf120 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ pip-delete-this-directory.txt # Virtual environments .venv/ venv/ +list/ ENV/ env/ .env diff --git a/fastapi_admin_kit/api/roles.py b/fastapi_admin_kit/api/roles.py index a6a3c2f..ef444f5 100644 --- a/fastapi_admin_kit/api/roles.py +++ b/fastapi_admin_kit/api/roles.py @@ -106,7 +106,7 @@ async def update_role( ) -@router.delete("/{role_id}", status_code=204) +@router.delete("/{role_id}", status_code=204, response_model=None) async def delete_role( request: Request, role_id: int, diff --git a/fastapi_admin_kit/api/search.py b/fastapi_admin_kit/api/search.py index d540982..7c64095 100644 --- a/fastapi_admin_kit/api/search.py +++ b/fastapi_admin_kit/api/search.py @@ -56,6 +56,7 @@ async def get_search_suggestions( if registry is None or not q.strip(): return {"suggestions": [], "query": q} + admin_path = request.app.state.admin_config["admin_path"] query_lower = q.strip().lower() suggestions: list[dict[str, Any]] = [] @@ -79,7 +80,7 @@ async def get_search_suggestions( "model": table_name, "label": verbose_name_plural, "sublabel": table_name, - "url": f"/admin/{table_name}", + "url": f"{admin_path}/{table_name}", } ) @@ -120,7 +121,7 @@ async def get_search_suggestions( "field": field_name, "label": f"{verbose_name_plural} → {field_label}", "sublabel": f"{table_name}.{field_name}", - "url": f"/admin/{table_name}?q={q}", + "url": f"{admin_path}/{table_name}?q={q}", } ) diff --git a/fastapi_admin_kit/auth/csrf.py b/fastapi_admin_kit/auth/csrf.py index c3b3598..e0b160c 100644 --- a/fastapi_admin_kit/auth/csrf.py +++ b/fastapi_admin_kit/auth/csrf.py @@ -224,9 +224,10 @@ async def auth_redirect_handler(request: Request, exc: HTTPException) -> Respons if exc.status_code == 401: accept = request.headers.get("accept", "") if "text/html" in accept: - login_url = "/admin/login" + admin_path = request.app.state.admin_config["admin_path"] + login_url = f"{admin_path}/login" current_path = request.url.path - if current_path != "/admin/login": + if current_path != f"{admin_path}/login": if request.url.query: login_url += f"?next={current_path}%3F{request.url.query}" else: diff --git a/fastapi_admin_kit/auth/views.py b/fastapi_admin_kit/auth/views.py index 1214667..87e0423 100644 --- a/fastapi_admin_kit/auth/views.py +++ b/fastapi_admin_kit/auth/views.py @@ -45,7 +45,8 @@ async def login_get( ) -> HTMLResponse: """GET /admin/login — show login page, redirect if already logged in.""" if session_payload is not None: - target = next if _is_safe_url(next) else "/admin/" + admin_path = request.app.state.admin_config["admin_path"] + target = next if _is_safe_url(next) else f"{admin_path}/" return RedirectResponse(url=target, status_code=status.HTTP_302_FOUND) jinja_env = request.app.state.admin_jinja_env @@ -90,7 +91,8 @@ async def login_post( if next and _is_safe_url(next): redirect_url = next else: - redirect_url = "/admin/" + admin_path = request.app.state.admin_config["admin_path"] + redirect_url = f"{admin_path}/" samesite = getattr( request.app.state.admin_state, "session_samesite", "strict" @@ -149,7 +151,8 @@ async def logout_post( request.app.state.admin_state, "session_samesite", "strict" ) response = RedirectResponse( - url="/admin/login", status_code=status.HTTP_302_FOUND + url=f"{request.app.state.admin_config['admin_path']}/login", + status_code=status.HTTP_302_FOUND ) response.delete_cookie( key=session_backend.cookie_name, diff --git a/fastapi_admin_kit/form/pipeline.py b/fastapi_admin_kit/form/pipeline.py index 1cb2cd7..42c1a92 100644 --- a/fastapi_admin_kit/form/pipeline.py +++ b/fastapi_admin_kit/form/pipeline.py @@ -27,8 +27,13 @@ def build_form_context( fieldsets: list[FieldsetContext] = [FieldsetContext(fields=[])] for field_meta in registered.form_fields: - col = next((c for c in registered.columns if c.name == field_meta.name), None) - rel = next((r for r in registered.relationships if r.name == field_meta.name), None) + col = next( + (c for c in registered.columns if c.name == field_meta.name), None + ) + rel = next( + (r for r in registered.relationships if r.name == field_meta.name), + None, + ) widget = registered.get_widget(field_meta.name) value = values.get(field_meta.name) @@ -39,6 +44,7 @@ def build_form_context( if value is None and rel is not None: try: from sqlalchemy import inspect as sa_inspect + mapper = sa_inspect(type(obj)) rel_prop = mapper.relationships.get(rel.name) if rel_prop is not None: @@ -62,9 +68,13 @@ def build_form_context( ): try: from sqlalchemy import inspect as sa_inspect + mapper = sa_inspect(type(obj)) rel_prop = mapper.relationships.get(rel.name) - if rel_prop is not None and rel_prop.direction.name == "MANYTOMANY": + if ( + rel_prop is not None + and rel_prop.direction.name == "MANYTOMANY" + ): value = [str(item.id) for item in value] except Exception: pass @@ -74,6 +84,13 @@ def build_form_context( widget_macro = widget.macro_name widget_ctx = widget.render_context(field_meta, value) widget_ctx["is_create"] = is_create + if request is not None: + admin_path = request.app.state.admin_config["admin_path"] + widget_ctx["admin_path"] = admin_path + if "search_url" in widget_ctx: + widget_ctx["search_url"] = widget_ctx["search_url"].replace( + "/admin/", f"{admin_path}/" + ) if obj is not None: widget_ctx["obj_id"] = getattr(obj, "id", "") if rel is not None and rel_labels: diff --git a/fastapi_admin_kit/inspection/__init__.py b/fastapi_admin_kit/inspection/__init__.py index a060f70..7e0c9b1 100644 --- a/fastapi_admin_kit/inspection/__init__.py +++ b/fastapi_admin_kit/inspection/__init__.py @@ -67,6 +67,34 @@ def get_pk_field(model: type) -> str | None: return tuple(col.key for col in pk_cols) +def cast_pk_value(model: type, value: Any) -> Any: + """Cast a string primary key value to the correct Python type. + + Inspects the model's primary key column type and converts the value + accordingly. Supports Integer, BigInteger, String, and UUID types. + Returns the original value if type cannot be determined. + """ + if value is None: + return None + mapper = inspect(model) + pk_cols = mapper.primary_key + if not pk_cols or len(pk_cols) != 1: + return value + pk_col = pk_cols[0] + from sqlalchemy import BigInteger, Integer + from sqlalchemy.dialects.postgresql import UUID as PG_UUID + from sqlalchemy.types import Uuid + + col_type = type(pk_col.type) + if col_type in (Integer, BigInteger): + return int(value) + if col_type in (PG_UUID, Uuid): + from uuid import UUID + + return UUID(str(value)) + return value + + def auto_label(name: str) -> str: """Auto-generate a human-readable label from a field name. diff --git a/fastapi_admin_kit/router.py b/fastapi_admin_kit/router.py index 0694353..ec83423 100644 --- a/fastapi_admin_kit/router.py +++ b/fastapi_admin_kit/router.py @@ -150,13 +150,14 @@ async def inline_edit_form( from sqlalchemy import select from sqlalchemy.orm import selectinload + from fastapi_admin_kit.inspection import cast_pk_value mapper = sa_inspect(registered.model) options = [ selectinload(getattr(registered.model, r.key)) for r in mapper.relationships ] stmt = select(registered.model).options(*options).where( - getattr(registered.model, registered.pk_field) == int(id) + getattr(registered.model, registered.pk_field) == cast_pk_value(registered.model, id) ) result = await session.execute(stmt) obj = result.scalar_one_or_none() @@ -209,13 +210,14 @@ async def inline_edit_save( from sqlalchemy import select from sqlalchemy.orm import selectinload + from fastapi_admin_kit.inspection import cast_pk_value mapper = sa_inspect(registered.model) options = [ selectinload(getattr(registered.model, r.key)) for r in mapper.relationships ] stmt = select(registered.model).options(*options).where( - getattr(registered.model, registered.pk_field) == int(id) + getattr(registered.model, registered.pk_field) == cast_pk_value(registered.model, id) ) result = await session.execute(stmt) obj = result.scalar_one_or_none() @@ -377,7 +379,8 @@ async def execute_row_action( if not action_obj: raise HTTPException(status_code=404, detail=f"Unknown action: {action_name}") - obj = await session.get(registered.model, int(id)) + from fastapi_admin_kit.inspection import cast_pk_value + obj = await session.get(registered.model, cast_pk_value(registered.model, id)) if not obj: raise HTTPException(status_code=404, detail="Not found") @@ -484,7 +487,8 @@ async def update_field( session = get_db_session(request) try: - obj = await session.get(registered.model, int(id)) + from fastapi_admin_kit.inspection import cast_pk_value + obj = await session.get(registered.model, cast_pk_value(registered.model, id)) if obj is None: raise HTTPException(status_code=404, detail="Object not found") diff --git a/fastapi_admin_kit/static/js/admin.js b/fastapi_admin_kit/static/js/admin.js index e89f650..0edd2fe 100644 --- a/fastapi_admin_kit/static/js/admin.js +++ b/fastapi_admin_kit/static/js/admin.js @@ -565,7 +565,7 @@ document.addEventListener('alpine:init', () => { return; } try { - const resp = await fetch(`/admin/search/suggestions?q=${encodeURIComponent(q)}`); + const resp = await fetch(`${window.__ADMIN_PATH__}/search/suggestions?q=${encodeURIComponent(q)}`); if (resp.ok) { const data = await resp.json(); this.results = data.suggestions || []; diff --git a/fastapi_admin_kit/templates/base.html b/fastapi_admin_kit/templates/base.html index 71508ac..4b9f1db 100644 --- a/fastapi_admin_kit/templates/base.html +++ b/fastapi_admin_kit/templates/base.html @@ -136,6 +136,9 @@
{% endif %} +