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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pip-delete-this-directory.txt
# Virtual environments
.venv/
venv/
list/
ENV/
env/
.env
Expand Down
2 changes: 1 addition & 1 deletion fastapi_admin_kit/api/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions fastapi_admin_kit/api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []

Expand All @@ -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}",
}
)

Expand Down Expand Up @@ -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}",
}
)

Expand Down
5 changes: 3 additions & 2 deletions fastapi_admin_kit/auth/csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 6 additions & 3 deletions fastapi_admin_kit/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 20 additions & 3 deletions fastapi_admin_kit/form/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions fastapi_admin_kit/inspection/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 8 additions & 4 deletions fastapi_admin_kit/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion fastapi_admin_kit/static/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];
Expand Down
3 changes: 3 additions & 0 deletions fastapi_admin_kit/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ <h3 class="modal__title" x-text="title"></h3>
<script>{{ _ui2.custom_js }}</script>
{% endif %}

<script>
window.__ADMIN_PATH__ = {{ admin_path | default('/admin') | tojson }};
</script>
<script src="/static/js/htmx-config.js"></script>
<script src="/static/js/admin.js?v={{ static_version }}"></script>
</body>
Expand Down
6 changes: 4 additions & 2 deletions fastapi_admin_kit/views/class_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 8 additions & 4 deletions fastapi_admin_kit/views/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions fastapi_admin_kit/views/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion fastapi_admin_kit/views/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading