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 %} + diff --git a/fastapi_admin_kit/views/class_views.py b/fastapi_admin_kit/views/class_views.py index c4afd42..da7be8a 100644 --- a/fastapi_admin_kit/views/class_views.py +++ b/fastapi_admin_kit/views/class_views.py @@ -171,7 +171,8 @@ async def _apply_m2m_from_data( if not pk: continue try: - loaded = await session.get(target_model, int(pk)) + from fastapi_admin_kit.inspection import cast_pk_value + loaded = await session.get(target_model, cast_pk_value(target_model, pk)) if loaded: objs.append(loaded) except (ValueError, TypeError): @@ -1021,7 +1022,8 @@ async def _search( if exclude_id: pk_col = getattr(model, self.registered.pk_field, None) if pk_col is not None: - base = base.where(pk_col != int(exclude_id)) + from fastapi_admin_kit.inspection import cast_pk_value + base = base.where(pk_col != cast_pk_value(model, exclude_id)) base = base.limit(limit) result = session.execute(base) diff --git a/fastapi_admin_kit/views/factory.py b/fastapi_admin_kit/views/factory.py index 1602bab..77b6dec 100644 --- a/fastapi_admin_kit/views/factory.py +++ b/fastapi_admin_kit/views/factory.py @@ -104,7 +104,8 @@ async def _apply_m2m_from_data( if not pk: continue try: - loaded = await session.get(target_model, int(pk)) + from fastapi_admin_kit.inspection import cast_pk_value + loaded = await session.get(target_model, cast_pk_value(target_model, pk)) if loaded: objs.append(loaded) except (ValueError, TypeError): @@ -457,7 +458,8 @@ def create_edit_form_view(self, registered: RegisteredModel): async def edit_form(request: Request, id: str, _: Any = None): templates = request.app.state.admin_jinja_env session = get_db_session(request) - 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") checker = await _resolve_permission_checker(request) @@ -483,7 +485,8 @@ def create_edit_submit_view(self, registered: RegisteredModel): async def edit_submit(request: Request, id: str, _: Any = None): templates = request.app.state.admin_jinja_env session = get_db_session(request) - 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") form_data = await request.form() @@ -555,7 +558,8 @@ async def delete_submit(request: Request, id: str, _: Any = None): await session.rollback() except Exception: pass - 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") try: diff --git a/fastapi_admin_kit/views/profile.py b/fastapi_admin_kit/views/profile.py index ec3671c..e79d23c 100644 --- a/fastapi_admin_kit/views/profile.py +++ b/fastapi_admin_kit/views/profile.py @@ -104,7 +104,10 @@ async def profile_update( user.full_name = full_name await session.flush() - return RedirectResponse(url="/admin/profile", status_code=302) + return RedirectResponse( + url=f"{request.app.state.admin_config['admin_path']}/profile", + status_code=302, + ) @router.get("/profile/password", response_class=HTMLResponse) @@ -200,7 +203,10 @@ async def password_change_post( # Clear session and redirect to login from fastapi_admin_kit.auth.csrf import CSRF_COOKIE_NAME - response = RedirectResponse(url="/admin/login", status_code=302) + response = RedirectResponse( + url=f"{request.app.state.admin_config['admin_path']}/login", + status_code=302, + ) session_backend = request.app.state.admin_session_backend samesite = getattr( request.app.state.admin_state, "session_samesite", "strict" diff --git a/fastapi_admin_kit/views/renderers.py b/fastapi_admin_kit/views/renderers.py index 0ea3ddc..23b5894 100644 --- a/fastapi_admin_kit/views/renderers.py +++ b/fastapi_admin_kit/views/renderers.py @@ -623,7 +623,8 @@ async def get_object(self, request: Request, id: Any) -> Any | None: for rel in mapper.relationships: if rel.direction.name == "MANYTOMANY": options.append(selectinload(getattr(self.registered.model, rel.key))) - int_id = int(id) if id is not None else None + from fastapi_admin_kit.inspection import cast_pk_value + int_id = cast_pk_value(self.registered.model, id) if options: from sqlalchemy import select stmt = select(self.registered.model).options(*options).where( diff --git a/fastapi_admin_kit/views/roles.py b/fastapi_admin_kit/views/roles.py index c0fa598..e80036a 100644 --- a/fastapi_admin_kit/views/roles.py +++ b/fastapi_admin_kit/views/roles.py @@ -22,7 +22,9 @@ async def _require_superuser( user: AdminUserProtocol = Depends(get_current_admin_user), ) -> AdminUserProtocol: if not getattr(user, "is_superuser", False): - raise HTTPException(status_code=403, detail="Superuser access required.") + raise HTTPException( + status_code=403, detail="Superuser access required." + ) return user @@ -36,15 +38,13 @@ async def tables_search( registry = request.app.state.admin_registry models = registry.all() - results = [ - {"id": m.table_name, "label": m.verbose_name} - for m in models - ] + results = [{"id": m.table_name, "label": m.verbose_name} for m in models] if q: q_lower = q.lower() results = [ - r for r in results + r + for r in results if q_lower in r["label"].lower() or q_lower in r["id"].lower() ] @@ -66,17 +66,22 @@ async def role_list_view( role_data = [] for role in roles: user_count = len(role.users) - role_data.append({ - "role": role, - "user_count": user_count, - }) + role_data.append( + { + "role": role, + "user_count": user_count, + } + ) return templates.TemplateResponse( request, "pages/roles.html", - await inject_sidebar_context(request, { - "roles": role_data, - }), + await inject_sidebar_context( + request, + { + "roles": role_data, + }, + ), ) @@ -91,11 +96,14 @@ async def role_create_view( return templates.TemplateResponse( request, "pages/role_form.html", - await inject_sidebar_context(request, { - "role": None, - "perm_data": {}, - "search_url": "/admin/tables/search", - }), + await inject_sidebar_context( + request, + { + "role": None, + "perm_data": {}, + "search_url": f"{request.app.state.admin_config['admin_path']}/tables/search", + }, + ), ) @@ -118,10 +126,16 @@ async def role_edit_view( model_map = {m.table_name: m.verbose_name for m in models} perms = ( - await session.execute( - select(AdminPermission).where(AdminPermission.role_id == role_id) + ( + await session.execute( + select(AdminPermission).where( + AdminPermission.role_id == role_id + ) + ) ) - ).scalars().all() + .scalars() + .all() + ) perm_data = {} for p in perms: @@ -136,11 +150,14 @@ async def role_edit_view( return templates.TemplateResponse( request, "pages/role_form.html", - await inject_sidebar_context(request, { - "role": role, - "perm_data": perm_data, - "search_url": "/admin/tables/search", - }), + await inject_sidebar_context( + request, + { + "role": role, + "perm_data": perm_data, + "search_url": f"{request.app.state.admin_config['admin_path']}/tables/search", + }, + ), ) @@ -167,10 +184,16 @@ async def role_save_view( perm_data = {} existing_perms = ( - await session.execute( - select(AdminPermission).where(AdminPermission.role_id == role_id) + ( + await session.execute( + select(AdminPermission).where( + AdminPermission.role_id == role_id + ) + ) ) - ).scalars().all() + .scalars() + .all() + ) existing_perm_map = {p.table_name: p for p in existing_perms} for table, data in perm_data.items(): @@ -200,7 +223,10 @@ async def role_save_view( await session.flush() - return RedirectResponse(url="/admin/roles", status_code=302) + return RedirectResponse( + url=f"{request.app.state.admin_config['admin_path']}/roles", + status_code=302, + ) @router.post("/roles/{role_id}/delete", response_class=RedirectResponse) @@ -227,4 +253,7 @@ async def role_delete_view( await session.delete(role) await session.flush() - return RedirectResponse(url="/admin/roles", status_code=302) + return RedirectResponse( + url=f"{request.app.state.admin_config['admin_path']}/roles", + status_code=302, + ) diff --git a/fastapi_admin_kit/views/totp.py b/fastapi_admin_kit/views/totp.py index aace1f7..43c2ade 100644 --- a/fastapi_admin_kit/views/totp.py +++ b/fastapi_admin_kit/views/totp.py @@ -189,7 +189,10 @@ async def totp_disable_post( totp_record.backup_codes = None await session.flush() - return RedirectResponse(url="/admin/profile/2fa", status_code=302) + return RedirectResponse( + url=f"{request.app.state.admin_config['admin_path']}/profile/2fa", + status_code=302, + ) @router.post("/profile/2fa/backup-codes") diff --git a/pyproject.toml b/pyproject.toml index ce9a0fd..4e7eaf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,7 @@ exclude = [ "docs/", "example.py", "example_sqlmodel.py", + "list/", "*.db", ".*", ]