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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,11 @@ site/
.DS_Store
Thumbs.db
*.log

# Node (Tailwind build, dev-only — compiled CSS is committed)
node_modules/
.npm/

# Compiled Tailwind bundle IS tracked (exception to dist/ above)
!fastapi_admin_kit/static/css/dist/
!fastapi_admin_kit/static/css/dist/**
15 changes: 15 additions & 0 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Thank you for your interest in contributing to FastAPI Admin Kit!

- Python 3.11 or higher
- uv (recommended) or pip
- Node.js 20+ and npm (only needed if you touch templates or CSS)

### Clone and Install

Expand Down Expand Up @@ -51,6 +52,20 @@ uv run mkdocs serve

Open `http://localhost:8000` in your browser.

### Tailwind CSS Development

The project uses Tailwind CSS v4 with a single-source config in `fastapi_admin_kit/static/css/style.css`.

**To develop on the CSS:**

```bash
npm install # install devDependencies (Tailwind, Prettier, etc.)
npm run build:css # compile once
npm run dev:css # watch mode (recompile on changes)
```

**The build output `fastapi_admin_kit/static/css/dist/tailwind.css` is committed** (per `.gitignore:81-83`) so pip consumers don't need to run Tailwind. If you change `style.css` or any template, re-run `npm run build:css` and commit the updated `dist/tailwind.css`.

## Project Structure

```
Expand Down
8 changes: 1 addition & 7 deletions example/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,6 @@ class ProductAdmin(ModelAdmin):
"price",
"stock",
"is_active",
"tags",
]
readonly_fields = ["created_at", "updated_at"]
verbose_name = "Product"
Expand Down Expand Up @@ -357,11 +356,6 @@ class ProductAdmin(ModelAdmin):
# Sortable
ordering_field = "sort_order"

# Conditional fields — show tags only when is_active is true
conditional_fields = {
"tags": {"show_when": "is_active", "values": ["1", "on", "true"]},
}

# Form UX
warn_unsaved_form = True
compressed_fields = True
Expand All @@ -385,7 +379,7 @@ def status(self, obj):
# Custom widgets
formfield_overrides = {
"description": WysiwygWidget(),
"tags": ArrayWidget(),
# "tags": ArrayWidget(),
}

@action(
Expand Down
2 changes: 1 addition & 1 deletion fastapi_admin_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,4 @@
"configure_notifications",
"notifications_router",
]
__version__ = "0.5.1"
__version__ = "0.6.0"
5 changes: 2 additions & 3 deletions fastapi_admin_kit/admin/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -1305,9 +1305,8 @@ def _icon(name: str, size: str = "", **kwargs) -> str:
_static_dir = _Path(__file__).parent.parent / "static"
_hash_data = b""
for _f in (
"css/tokens.css",
"css/presets.css",
"css/admin.css",
"css/style.css",
"css/dist/tailwind.css",
"js/admin.js",
):
_fp = _static_dir / _f
Expand Down
18 changes: 9 additions & 9 deletions fastapi_admin_kit/auth/csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,15 @@ def validate_csrf_token(request: Request, csrf_token: str | None = None) -> None
detail="CSRF session cookie missing. Please refresh the page and try again.",
)

# Verify both tokens have valid HMAC signatures
# Verify both tokens have valid HMAC signatures.
# Both the form token and the cookie token must be independently valid —
# i.e. signed by the same secret key. This is the security invariant of
# the double-submit cookie pattern. We do NOT require the two tokens to
# carry identical payloads: the form embeds the token generated at GET
# time, while the browser may legitimately send a slightly older (but
# still valid) cookie if it was already present before the GET response.
# Requiring payload equality caused spurious "Invalid CSRF cookie" errors
# when the browser held a cookie from the previous page load.
if not _verify_csrf_token(secret_key, form_token):
raise HTTPException(
status_code=403,
Expand All @@ -136,14 +144,6 @@ def validate_csrf_token(request: Request, csrf_token: str | None = None) -> None
status_code=403,
detail="Invalid CSRF cookie. Please refresh the page and try again.",
)
# Compare the inner payloads (timestamp + random)
form_parts = form_token.rsplit(".", 2)
cookie_parts = cookie_token.rsplit(".", 2)
if form_parts[0] != cookie_parts[0] or form_parts[1] != cookie_parts[1]:
raise HTTPException(
status_code=403,
detail="CSRF token mismatch. Please refresh the page and try again.",
)


async def require_csrf_token(request: Request) -> None:
Expand Down
10 changes: 8 additions & 2 deletions fastapi_admin_kit/registry/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ def form_fields(self) -> list[Any]:
@property
def list_fields(self) -> list[str]:
if self.admin.list_display:
valid = {c.name for c in self.columns}
return [f for f in self.admin.list_display if f in valid]
# Registration-time validation (validate_admin_fields) already
# verified every name exists on the model, so return as-is.
return list(self.admin.list_display)
return [c.name for c in self.columns if not c.primary_key]

def get_widget(self, field_name: str, resolver: WidgetResolver | None = None) -> Widget:
Expand Down Expand Up @@ -133,6 +134,11 @@ def build_registered_model(
# Inspect using the injected inspector
columns, relationships = registry._inspector.inspect_model(model)

# Validate admin field references against actual model fields.
# This catches typos in list_display / exclude / readonly_fields /
# formfield_overrides at startup rather than silently ignoring them.
registry._validator.validate_admin_fields(model, admin_class, columns, relationships)

if admin is None:
admin = _ModelAdmin()
table_name = model.__tablename__
Expand Down
83 changes: 82 additions & 1 deletion fastapi_admin_kit/registry/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from fastapi_admin_kit.registry.core import AdminRegistry
Expand Down Expand Up @@ -41,6 +41,87 @@ def validate_model_registration(
self._validate_is_sqlalchemy_model(model)
self._check_table_name_conflicts(model)

def validate_admin_fields(
self,
model: type,
admin_class: type | None,
columns: list[Any],
relationships: list[Any],
) -> None:
"""Validate that all field names referenced in the admin class actually
exist on the model.

Checked attributes: ``list_display``, ``exclude``, ``readonly_fields``,
and ``formfield_overrides``.

Args:
model: The registered model class.
admin_class: The ModelAdmin subclass (or ``None`` for the default).
columns: Inspected column descriptors (each has a ``.name``).
relationships: Inspected relationship descriptors (each has a ``.name``).

Raises:
ValueError: With a descriptive message listing every unknown field.
"""
if admin_class is None:
return # default ModelAdmin has no user-supplied field lists

# Build the set of all valid field names for this model
valid_fields: set[str] = {c.name for c in columns} | {r.name for r in relationships}

model_name = getattr(model, "__name__", str(model))
admin_name = admin_class.__name__

# Also allow the admin class's own methods / @column-decorated attributes
# as valid "fields" so that display-only computed columns don't raise.
admin_methods: set[str] = {
name
for name in dir(admin_class)
if not name.startswith("_") and callable(getattr(admin_class, name, None))
}

def _check(field_list: list[str] | None, attr_name: str) -> list[str]:
if not field_list:
return []
return [f for f in field_list if f not in valid_fields and f not in admin_methods]

errors: list[str] = []

bad = _check(getattr(admin_class, "list_display", None), "list_display")
if bad:
errors.append(
f" list_display: unknown field(s) {bad!r}\n"
f" Valid fields: {sorted(valid_fields)!r}"
)

bad = _check(getattr(admin_class, "exclude", None), "exclude")
if bad:
errors.append(
f" exclude: unknown field(s) {bad!r}\n Valid fields: {sorted(valid_fields)!r}"
)

bad = _check(getattr(admin_class, "readonly_fields", None), "readonly_fields")
if bad:
errors.append(
f" readonly_fields: unknown field(s) {bad!r}\n"
f" Valid fields: {sorted(valid_fields)!r}"
)

fo = getattr(admin_class, "formfield_overrides", None)
if fo:
bad_fo = [f for f in fo if f not in valid_fields]
if bad_fo:
errors.append(
f" formfield_overrides: unknown field(s) {bad_fo!r}\n"
f" Valid fields: {sorted(valid_fields)!r}"
)

if errors:
raise ValueError(
f"{admin_name} for model '{model_name}' references field(s) that "
f"do not exist on the model:\n" + "\n".join(errors)
)

def _validate_is_sqlalchemy_model(self, model: type) -> None:
"""Validate that the model is a SQLAlchemy or SQLModel model.

Expand Down
47 changes: 32 additions & 15 deletions fastapi_admin_kit/static/css/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -680,9 +680,23 @@ img { max-width: 100%; display: block; }
/* ── Field Wrapper ───────────────────────────────────────────────────────── */

.field-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
display: grid;
grid-template-columns: 180px 1fr;
gap: var(--space-3);
align-items: start;
}

/* Default: input column is 70% of the row */
.field-wrapper:not(.field-wrapper--full) .field-wrapper__body {
max-width: 70%;
}

/* Opt-in: input column spans 100% of the row */
.field-wrapper--full {
grid-template-columns: 180px 1fr;
}
.field-wrapper--full .field-wrapper__body {
max-width: 100%;
}

.field-wrapper.has-error .form-input,
Expand All @@ -702,7 +716,9 @@ img { max-width: 100%; display: block; }

.required-star {
color: var(--danger-500);
font-weight: var(--font-normal);
font-weight: var(--font-semibold);
margin-left: 2px;
font-size: var(--text-sm);
}

.field-help {
Expand Down Expand Up @@ -794,8 +810,8 @@ img { max-width: 100%; display: block; }
}

.fieldset__fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
display: flex;
flex-direction: column;
gap: var(--space-4);
}

Expand Down Expand Up @@ -2369,8 +2385,6 @@ img { max-width: 100%; display: block; }
.form-input:focus, .form-select:focus { border-color: var(--primary-500); box-shadow: var(--shadow-focus); }
.form-input[readonly] { background: var(--surface-inset); color: var(--text-secondary); cursor: not-allowed; }

.field-wrapper { margin-bottom: var(--space-4); }
.field-label { display: block; font-size: var(--text-sm); font-weight: var(--font-medium); color: var(--text-primary); margin-bottom: var(--space-1); }
.required-star { color: var(--danger-500); margin-left: 2px; }
.field-errors { margin-top: var(--space-1); }
.field-errors li { font-size: var(--text-xs); color: var(--danger-500); }
Expand Down Expand Up @@ -2607,13 +2621,13 @@ img { max-width: 100%; display: block; }
.rotate-180 { transform: rotate(180deg); }
.-rotate-90 { transform: rotate(-90deg); }
.translate-y-2 { transform: translateY(var(--space-2)); }
.-translate-y-1\\/2 { transform: translateY(-50%); }
.-translate-y-1\/2 { transform: translateY(-50%); }
.translate-x-1 { transform: translateX(var(--space-1)); }
.translate-x-6 { transform: translateX(1.5rem); }

.hover\\:bg-gray-50:hover, [class*="hover:bg-"][class*="gray-50"]:hover { background: var(--surface-inset); }
.hover\\:text-primary-600:hover, [class*="hover:text-primary-600"]:hover { color: var(--primary-600); }
.hover\\:underline:hover { text-decoration: underline; }
.hover\:bg-gray-50:hover, [class*="hover:bg-"][class*="gray-50"]:hover { background: var(--surface-inset); }
.hover\:text-primary-600:hover, [class*="hover:text-primary-600"]:hover { color: var(--primary-600); }
.hover\:underline:hover { text-decoration: underline; }

.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }

Expand Down Expand Up @@ -2722,9 +2736,8 @@ img { max-width: 100%; display: block; }
.table--relaxed th, .table--relaxed td { padding: var(--space-4) var(--space-5); }

/* Form layout variants */
.form--one-column .fieldset__fields { grid-template-columns: 1fr; }
.form--compact .field-wrapper { gap: 4px; }
.form--relaxed .field-wrapper { gap: 12px; }
.form--relaxed .field-wrapper { gap: 16px; }

/* Sidebar style variants */
.sidebar--compact .nav-link { padding: var(--space-1) 0; font-size: var(--text-xs); }
Expand Down Expand Up @@ -2801,10 +2814,14 @@ img { max-width: 100%; display: block; }
grid-template-columns: repeat(2, 1fr);
}

.fieldset__fields {
.field-wrapper {
grid-template-columns: 1fr;
}

.field-wrapper .field-wrapper__body {
max-width: 100%;
}

.filter-bar {
flex-direction: column;
align-items: stretch;
Expand Down
2 changes: 2 additions & 0 deletions fastapi_admin_kit/static/css/dist/tailwind.css

Large diffs are not rendered by default.

Loading
Loading