fastapi-admin-kit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. fastapi_admin_kit/__init__.py +73 -0
  2. fastapi_admin_kit/actions/__init__.py +63 -0
  3. fastapi_admin_kit/actions/base.py +68 -0
  4. fastapi_admin_kit/actions/registry.py +43 -0
  5. fastapi_admin_kit/admin/__init__.py +17 -0
  6. fastapi_admin_kit/admin/admin_config.py +95 -0
  7. fastapi_admin_kit/admin/admin_database.py +138 -0
  8. fastapi_admin_kit/admin/admin_router.py +74 -0
  9. fastapi_admin_kit/admin/admin_template.py +203 -0
  10. fastapi_admin_kit/admin/builtin_models.py +284 -0
  11. fastapi_admin_kit/admin/core.py +1036 -0
  12. fastapi_admin_kit/admin/decorators.py +70 -0
  13. fastapi_admin_kit/admin/state.py +76 -0
  14. fastapi_admin_kit/admin.py +728 -0
  15. fastapi_admin_kit/api/__init__.py +44 -0
  16. fastapi_admin_kit/api/auth.py +342 -0
  17. fastapi_admin_kit/api/crud.py +128 -0
  18. fastapi_admin_kit/api/deps.py +79 -0
  19. fastapi_admin_kit/api/roles.py +128 -0
  20. fastapi_admin_kit/api/schema_generator.py +171 -0
  21. fastapi_admin_kit/api/schemas.py +81 -0
  22. fastapi_admin_kit/api/search.py +132 -0
  23. fastapi_admin_kit/audit/__init__.py +36 -0
  24. fastapi_admin_kit/audit/context.py +62 -0
  25. fastapi_admin_kit/audit/diff.py +77 -0
  26. fastapi_admin_kit/audit/event_bus.py +96 -0
  27. fastapi_admin_kit/audit/events.py +48 -0
  28. fastapi_admin_kit/audit/listener.py +159 -0
  29. fastapi_admin_kit/audit/logger.py +28 -0
  30. fastapi_admin_kit/audit/middleware.py +39 -0
  31. fastapi_admin_kit/audit/models.py +53 -0
  32. fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
  33. fastapi_admin_kit/auth/__init__.py +34 -0
  34. fastapi_admin_kit/auth/backend.py +95 -0
  35. fastapi_admin_kit/auth/csrf.py +240 -0
  36. fastapi_admin_kit/auth/dependencies.py +150 -0
  37. fastapi_admin_kit/auth/identity.py +181 -0
  38. fastapi_admin_kit/auth/models.py +246 -0
  39. fastapi_admin_kit/auth/password.py +35 -0
  40. fastapi_admin_kit/auth/permissions.py +205 -0
  41. fastapi_admin_kit/auth/protocol.py +22 -0
  42. fastapi_admin_kit/auth/ratelimit.py +88 -0
  43. fastapi_admin_kit/auth/router.py +10 -0
  44. fastapi_admin_kit/auth/session.py +79 -0
  45. fastapi_admin_kit/auth/totp.py +83 -0
  46. fastapi_admin_kit/auth/views.py +165 -0
  47. fastapi_admin_kit/cli.py +229 -0
  48. fastapi_admin_kit/config/__init__.py +19 -0
  49. fastapi_admin_kit/config/audit.py +18 -0
  50. fastapi_admin_kit/config/auth.py +54 -0
  51. fastapi_admin_kit/config/behavior.py +27 -0
  52. fastapi_admin_kit/config/nav.py +32 -0
  53. fastapi_admin_kit/config/storage.py +22 -0
  54. fastapi_admin_kit/config/theme.py +215 -0
  55. fastapi_admin_kit/config/ui.py +147 -0
  56. fastapi_admin_kit/dashboard/__init__.py +64 -0
  57. fastapi_admin_kit/db.py +133 -0
  58. fastapi_admin_kit/exceptions.py +5 -0
  59. fastapi_admin_kit/field_types.py +81 -0
  60. fastapi_admin_kit/filters/__init__.py +21 -0
  61. fastapi_admin_kit/filters/base.py +170 -0
  62. fastapi_admin_kit/filters/registry.py +68 -0
  63. fastapi_admin_kit/flash.py +45 -0
  64. fastapi_admin_kit/form/__init__.py +1 -0
  65. fastapi_admin_kit/form/pipeline.py +106 -0
  66. fastapi_admin_kit/inspection/__init__.py +117 -0
  67. fastapi_admin_kit/inspection/registry.py +253 -0
  68. fastapi_admin_kit/inspection.py +115 -0
  69. fastapi_admin_kit/modeladmin.py +375 -0
  70. fastapi_admin_kit/models/__init__.py +7 -0
  71. fastapi_admin_kit/models/base.py +7 -0
  72. fastapi_admin_kit/nav.py +208 -0
  73. fastapi_admin_kit/pagination/__init__.py +14 -0
  74. fastapi_admin_kit/pagination/base.py +40 -0
  75. fastapi_admin_kit/pagination/cursor.py +97 -0
  76. fastapi_admin_kit/pagination/dynamic.py +48 -0
  77. fastapi_admin_kit/pagination/offset.py +42 -0
  78. fastapi_admin_kit/plugins/__init__.py +1 -0
  79. fastapi_admin_kit/py.typed +0 -0
  80. fastapi_admin_kit/registry/__init__.py +5 -0
  81. fastapi_admin_kit/registry/core.py +287 -0
  82. fastapi_admin_kit/registry/validation.py +107 -0
  83. fastapi_admin_kit/registry.py +15 -0
  84. fastapi_admin_kit/router.py +335 -0
  85. fastapi_admin_kit/static/css/admin.css +4736 -0
  86. fastapi_admin_kit/static/css/presets.css +317 -0
  87. fastapi_admin_kit/static/css/tokens.css +217 -0
  88. fastapi_admin_kit/static/css/variables.css +74 -0
  89. fastapi_admin_kit/static/icons/heroicons.svg +160 -0
  90. fastapi_admin_kit/static/js/admin.js +692 -0
  91. fastapi_admin_kit/static/js/htmx-config.js +42 -0
  92. fastapi_admin_kit/storage/__init__.py +6 -0
  93. fastapi_admin_kit/storage/base.py +48 -0
  94. fastapi_admin_kit/storage/local.py +73 -0
  95. fastapi_admin_kit/templates/base.html +142 -0
  96. fastapi_admin_kit/templates/macros/form_fields.html +660 -0
  97. fastapi_admin_kit/templates/macros/icons.html +50 -0
  98. fastapi_admin_kit/templates/macros/table.html +108 -0
  99. fastapi_admin_kit/templates/macros/widgets.html +159 -0
  100. fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
  101. fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
  102. fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
  103. fastapi_admin_kit/templates/pages/audit_log.html +102 -0
  104. fastapi_admin_kit/templates/pages/dashboard.html +295 -0
  105. fastapi_admin_kit/templates/pages/detail.html +183 -0
  106. fastapi_admin_kit/templates/pages/form.html +119 -0
  107. fastapi_admin_kit/templates/pages/list.html +277 -0
  108. fastapi_admin_kit/templates/pages/login.html +85 -0
  109. fastapi_admin_kit/templates/pages/profile/password.html +78 -0
  110. fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
  111. fastapi_admin_kit/templates/pages/role_form.html +75 -0
  112. fastapi_admin_kit/templates/pages/roles/form.html +117 -0
  113. fastapi_admin_kit/templates/pages/roles/list.html +69 -0
  114. fastapi_admin_kit/templates/pages/roles.html +77 -0
  115. fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
  116. fastapi_admin_kit/templates/pages/users/form.html +229 -0
  117. fastapi_admin_kit/templates/pages/users/list.html +83 -0
  118. fastapi_admin_kit/templates/partials/command_palette.html +52 -0
  119. fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
  120. fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
  121. fastapi_admin_kit/templates/partials/head.html +21 -0
  122. fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
  123. fastapi_admin_kit/templates/partials/list_table.html +178 -0
  124. fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
  125. fastapi_admin_kit/templates/partials/pagination.html +82 -0
  126. fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
  127. fastapi_admin_kit/templates/partials/scripts.html +13 -0
  128. fastapi_admin_kit/templates/partials/sidebar.html +94 -0
  129. fastapi_admin_kit/templates/partials/topbar.html +95 -0
  130. fastapi_admin_kit/types.py +145 -0
  131. fastapi_admin_kit/validation.py +43 -0
  132. fastapi_admin_kit/views/__init__.py +78 -0
  133. fastapi_admin_kit/views/audit.py +134 -0
  134. fastapi_admin_kit/views/bulk.py +28 -0
  135. fastapi_admin_kit/views/class_views.py +1040 -0
  136. fastapi_admin_kit/views/context.py +588 -0
  137. fastapi_admin_kit/views/dashboard.py +162 -0
  138. fastapi_admin_kit/views/delete.py +31 -0
  139. fastapi_admin_kit/views/extra.py +65 -0
  140. fastapi_admin_kit/views/factory.py +667 -0
  141. fastapi_admin_kit/views/form.py +159 -0
  142. fastapi_admin_kit/views/list.py +28 -0
  143. fastapi_admin_kit/views/profile.py +219 -0
  144. fastapi_admin_kit/views/protocols.py +54 -0
  145. fastapi_admin_kit/views/renderers.py +634 -0
  146. fastapi_admin_kit/views/roles.py +230 -0
  147. fastapi_admin_kit/views/search.py +31 -0
  148. fastapi_admin_kit/views/settings.py +31 -0
  149. fastapi_admin_kit/views/sidebar.py +101 -0
  150. fastapi_admin_kit/views/totp.py +249 -0
  151. fastapi_admin_kit/views/users.py +347 -0
  152. fastapi_admin_kit/views.py +117 -0
  153. fastapi_admin_kit/widgets/__init__.py +44 -0
  154. fastapi_admin_kit/widgets/base.py +44 -0
  155. fastapi_admin_kit/widgets/inputs.py +363 -0
  156. fastapi_admin_kit/widgets/registry.py +110 -0
  157. fastapi_admin_kit/widgets/relation.py +70 -0
  158. fastapi_admin_kit/widgets/resolver.py +102 -0
  159. fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
  160. fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
  161. fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
  162. fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
  163. fastapi_admin_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,96 @@
1
+ """Audit event bus — publish/subscribe system for audit events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from fastapi_admin_kit.audit.diff import snapshot
9
+ from fastapi_admin_kit.audit.events import AuditEvent
10
+
11
+
12
+ class AuditEventBus:
13
+ """Central event bus for audit events.
14
+
15
+ The listener publishes events here. Loggers and other subscribers
16
+ consume them. This decouples the SQLAlchemy event hooks from the
17
+ audit logging implementation.
18
+ """
19
+
20
+ def __init__(self) -> None:
21
+ self._listeners: dict[str, list[Callable[[AuditEvent], None]]] = {
22
+ "CREATE": [],
23
+ "UPDATE": [],
24
+ "DELETE": [],
25
+ }
26
+
27
+ def subscribe(
28
+ self, event_type: str, listener: Callable[[AuditEvent], None]
29
+ ) -> None:
30
+ """Register a listener for a specific event type.
31
+
32
+ Args:
33
+ event_type: "CREATE", "UPDATE", or "DELETE"
34
+ listener: Callable that receives an AuditEvent
35
+ """
36
+ if event_type not in self._listeners:
37
+ self._listeners[event_type] = []
38
+ self._listeners[event_type].append(listener)
39
+
40
+ def publish(self, event: AuditEvent) -> None:
41
+ """Publish an event to all listeners of its type.
42
+
43
+ Args:
44
+ event: The AuditEvent to publish
45
+ """
46
+ for listener in self._listeners.get(event.event_type, []):
47
+ listener(event)
48
+
49
+ def emit_for_object(
50
+ self,
51
+ obj: Any,
52
+ event_type: str,
53
+ context: dict[str, Any],
54
+ changes: dict[str, Any] | None = None,
55
+ snapshot_data: dict[str, Any] | None = None,
56
+ ) -> None:
57
+ """Build an AuditEvent from a SQLAlchemy object and publish it.
58
+
59
+ All data is extracted from *snapshot_data* to avoid touching the
60
+ ORM object — critical inside ``after_flush`` where attributes are
61
+ expired on async sessions.
62
+
63
+ Args:
64
+ obj: The SQLAlchemy model instance (used only for class/table
65
+ metadata which are class-level, not instance attributes)
66
+ event_type: "CREATE", "UPDATE", or "DELETE"
67
+ context: Audit context dict
68
+ changes: Pre-computed diff (used for UPDATE)
69
+ snapshot_data: Pre-computed snapshot dict
70
+ """
71
+ obj_snapshot = snapshot_data if snapshot_data is not None else snapshot(obj)
72
+
73
+ # Extract id and repr from the snapshot to avoid accessing
74
+ # potentially-expired instance attributes.
75
+ object_id = str(obj_snapshot.get("id", ""))
76
+ object_repr = str(obj_snapshot.get("id", obj.__class__.__name__))[:255]
77
+
78
+ if event_type == "UPDATE" and changes is not None:
79
+ event_changes = changes
80
+ else:
81
+ event_changes = None
82
+
83
+ event = AuditEvent(
84
+ event_type=event_type,
85
+ model_name=obj.__class__.__name__,
86
+ table_name=obj.__tablename__,
87
+ object_id=object_id,
88
+ object_repr=object_repr,
89
+ changes=event_changes,
90
+ full_snapshot=obj_snapshot,
91
+ user_id=context.get("user_id"),
92
+ user_email=context.get("user_email"),
93
+ ip_address=context.get("ip_address"),
94
+ user_agent=context.get("user_agent"),
95
+ )
96
+ self.publish(event)
@@ -0,0 +1,48 @@
1
+ """Audit events — data structures for audit events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ from dataclasses import asdict, dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class AuditEvent:
12
+ """Represents a single audit event (CREATE, UPDATE, or DELETE).
13
+
14
+ This is a pure data structure with no side effects, making it
15
+ easy to test and serialize.
16
+ """
17
+
18
+ event_type: str # "CREATE" | "UPDATE" | "DELETE"
19
+ model_name: str
20
+ table_name: str
21
+ object_id: str
22
+ object_repr: str = ""
23
+ changes: dict[str, Any] | None = None
24
+ full_snapshot: dict[str, Any] | None = None
25
+ user_id: int | None = None
26
+ user_email: str | None = None
27
+ ip_address: str | None = None
28
+ user_agent: str | None = None
29
+ timestamp: datetime.datetime | None = None
30
+
31
+ def __post_init__(self) -> None:
32
+ if self.timestamp is None:
33
+ self.timestamp = datetime.datetime.now(datetime.UTC)
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ """Serialize the event to a dictionary."""
37
+ data = asdict(self)
38
+ if self.timestamp is not None:
39
+ data["timestamp"] = self.timestamp.isoformat()
40
+ return data
41
+
42
+ @classmethod
43
+ def from_dict(cls, data: dict[str, Any]) -> AuditEvent:
44
+ """Deserialize an event from a dictionary."""
45
+ ts = data.get("timestamp")
46
+ if isinstance(ts, str):
47
+ data["timestamp"] = datetime.datetime.fromisoformat(ts)
48
+ return cls(**data)
@@ -0,0 +1,159 @@
1
+ """Audit listener — SQLAlchemy event listeners that write audit rows atomically.
2
+
3
+ AuditLog rows are created inside ``before_flush`` and added to the session
4
+ via ``session.add()``. SQLAlchemy re-runs ``before_flush`` until no new
5
+ pending objects appear, so the audit rows ride along in the same flush pass.
6
+ No IO, no queries, no ``MissingGreenlet`` — just already-loaded attributes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from sqlalchemy import event
14
+ from sqlalchemy.orm import Session
15
+ from sqlalchemy.orm.attributes import instance_state
16
+
17
+ from fastapi_admin_kit.audit.context import get_audit_context
18
+ from fastapi_admin_kit.audit.diff import serialize_value
19
+ from fastapi_admin_kit.audit.models import AuditLog
20
+
21
+
22
+ def is_registered_model(obj: Any, registry: Any) -> bool:
23
+ """Check if a model class is registered with the admin."""
24
+ if not hasattr(obj, "__tablename__"):
25
+ return False
26
+ table_name = getattr(obj, "__tablename__")
27
+ return registry.get(table_name) is not None
28
+
29
+
30
+ def _snapshot_from_committed(obj: Any) -> dict[str, Any]:
31
+ """Snapshot column values from the committed (pre-flush) state.
32
+
33
+ Uses SQLAlchemy attribute history to read the *old* values that were
34
+ in the database before the current pending changes. No attribute
35
+ access that could trigger lazy-load I/O.
36
+ """
37
+ if not hasattr(obj, "__table__"):
38
+ return {}
39
+ mapper = instance_state(obj).manager.mapper
40
+ data: dict[str, Any] = {}
41
+ for column in mapper.columns:
42
+ attr = instance_state(obj).attrs[column.key]
43
+ history = attr.history
44
+ if history.deleted:
45
+ data[column.key] = serialize_value(history.deleted[0])
46
+ elif history.unchanged:
47
+ data[column.key] = serialize_value(history.unchanged[0])
48
+ else:
49
+ data[column.key] = serialize_value(getattr(obj, column.key))
50
+ return data
51
+
52
+
53
+ def _snapshot_current(obj: Any) -> dict[str, Any]:
54
+ """Snapshot all mapped columns of a SQLAlchemy model instance."""
55
+ if not hasattr(obj, "__table__"):
56
+ return {}
57
+ from sqlalchemy.inspection import inspect as sa_inspect
58
+
59
+ mapper = sa_inspect(obj.__class__)
60
+ data: dict[str, Any] = {}
61
+ for column in mapper.columns:
62
+ data[column.key] = serialize_value(getattr(obj, column.key))
63
+ return data
64
+
65
+
66
+ def _compute_diff(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
67
+ """Compute changed fields between two snapshots.
68
+
69
+ Returns ``{"field": {"old": ..., "new": ...}}`` for each difference.
70
+ """
71
+ diff: dict[str, Any] = {}
72
+ all_keys = set(before.keys()) | set(after.keys())
73
+ for key in all_keys:
74
+ old_val = before.get(key)
75
+ new_val = after.get(key)
76
+ if old_val != new_val:
77
+ diff[key] = {"old": old_val, "new": new_val}
78
+ return diff
79
+
80
+
81
+ def _build_audit_row(
82
+ obj: Any,
83
+ action: str,
84
+ context: dict[str, Any],
85
+ *,
86
+ changes: dict[str, Any] | None = None,
87
+ snapshot_data: dict[str, Any] | None = None,
88
+ ) -> AuditLog:
89
+ """Create an AuditLog row from an ORM object and audit context."""
90
+ snap = snapshot_data if snapshot_data is not None else _snapshot_current(obj)
91
+ return AuditLog(
92
+ action=action,
93
+ model_name=type(obj).__name__,
94
+ table_name=obj.__tablename__,
95
+ object_id=str(snap.get("id", getattr(obj, "id", ""))),
96
+ object_repr=str(obj)[:500],
97
+ changes=changes,
98
+ full_snapshot=snap,
99
+ user_id=context.get("user_id"),
100
+ user_email=context.get("user_email"),
101
+ ip_address=context.get("ip_address"),
102
+ user_agent=context.get("user_agent"),
103
+ )
104
+
105
+
106
+ def attach_audit_listener(
107
+ session_factory: Any,
108
+ registry: Any,
109
+ ) -> None:
110
+ """Set up SQLAlchemy ``before_flush`` listener for audit logging.
111
+
112
+ Args:
113
+ session_factory: The session factory (sync or async).
114
+ registry: The AdminRegistry instance.
115
+ """
116
+
117
+ @event.listens_for(Session, "before_flush")
118
+ def before_flush(session: Session, flush_context: Any, instances: Any) -> None:
119
+ """Create AuditLog rows for all tracked mutations.
120
+
121
+ Runs inside the same flush pass — ``session.add()`` puts the
122
+ AuditLog into the pending set and SQLAlchemy will re-run
123
+ ``before_flush`` until no new objects appear. No queries, no
124
+ lazy-loads, only already-loaded attribute history.
125
+ """
126
+ context = get_audit_context()
127
+
128
+ # ── INSERT ──────────────────────────────────────────────────
129
+ for obj in list(session.new):
130
+ if not is_registered_model(obj, registry):
131
+ continue
132
+ if obj.__tablename__ == AuditLog.__tablename__:
133
+ continue
134
+ row = _build_audit_row(obj, "CREATE", context)
135
+ session.add(row)
136
+
137
+ # ── UPDATE ──────────────────────────────────────────────────
138
+ for obj in list(session.dirty):
139
+ if not is_registered_model(obj, registry):
140
+ continue
141
+ if obj.__tablename__ == AuditLog.__tablename__:
142
+ continue
143
+ before = _snapshot_from_committed(obj)
144
+ after = _snapshot_current(obj)
145
+ diff = _compute_diff(before, after)
146
+ if not diff:
147
+ continue
148
+ row = _build_audit_row(obj, "UPDATE", context, changes=diff, snapshot_data=after)
149
+ session.add(row)
150
+
151
+ # ── DELETE ──────────────────────────────────────────────────
152
+ for obj in list(session.deleted):
153
+ if not is_registered_model(obj, registry):
154
+ continue
155
+ if obj.__tablename__ == AuditLog.__tablename__:
156
+ continue
157
+ snap = _snapshot_current(obj)
158
+ row = _build_audit_row(obj, "DELETE", context, snapshot_data=snap)
159
+ session.add(row)
@@ -0,0 +1,28 @@
1
+ """Audit logger — interface for audit log persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from fastapi_admin_kit.audit.events import AuditEvent
8
+
9
+
10
+ class AuditLogger(ABC):
11
+ """Abstract interface for persisting audit events.
12
+
13
+ Implementations handle the actual storage (database, file, etc.).
14
+ This separation allows testing audit logic without a database, and
15
+ swapping storage backends without changing the event flow.
16
+ """
17
+
18
+ @abstractmethod
19
+ def log_create(self, event: AuditEvent) -> None:
20
+ """Persist a CREATE audit event."""
21
+
22
+ @abstractmethod
23
+ def log_update(self, event: AuditEvent) -> None:
24
+ """Persist an UPDATE audit event."""
25
+
26
+ @abstractmethod
27
+ def log_delete(self, event: AuditEvent) -> None:
28
+ """Persist a DELETE audit event."""
@@ -0,0 +1,39 @@
1
+ """Audit middleware — sets and clears audit context from the request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from starlette.middleware.base import BaseHTTPMiddleware
6
+ from starlette.requests import Request
7
+ from starlette.responses import Response
8
+
9
+ from fastapi_admin_kit.audit.context import clear_audit_context, set_audit_context
10
+
11
+
12
+ class AuditContextMiddleware(BaseHTTPMiddleware):
13
+ """Middleware to set audit context from the request and clear it after.
14
+
15
+ Sets IP address and user-agent before the handler runs. The user
16
+ identity (user_id, user_email) is added later by
17
+ :func:`fastapi_admin_kit.auth.identity.resolve_user` once the auth
18
+ dependency resolves the current user — which always happens before
19
+ any ``session.commit()`` that would trigger audit listeners.
20
+ """
21
+
22
+ async def dispatch(self, request: Request, call_next) -> Response:
23
+ context_data: dict = {}
24
+
25
+ if request.client is not None:
26
+ context_data["ip_address"] = request.client.host
27
+
28
+ user_agent = request.headers.get("user-agent")
29
+ if user_agent:
30
+ context_data["user_agent"] = user_agent
31
+
32
+ if context_data:
33
+ set_audit_context(context_data)
34
+
35
+ response = await call_next(request)
36
+
37
+ clear_audit_context()
38
+
39
+ return response
@@ -0,0 +1,53 @@
1
+ """SQLAlchemy model for the admin audit log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlalchemy import (
6
+ JSON,
7
+ Column,
8
+ DateTime,
9
+ ForeignKey,
10
+ Index,
11
+ Integer,
12
+ String,
13
+ Text,
14
+ )
15
+ from sqlalchemy.sql import func
16
+
17
+ from fastapi_admin_kit.models.base import Base
18
+
19
+
20
+ class AuditLog(Base):
21
+ __tablename__ = "admin_audit_log"
22
+ __table_args__ = (
23
+ Index("idx_audit_model", "model_name", "table_name"),
24
+ Index("idx_audit_user", "user_id"),
25
+ Index("idx_audit_timestamp", "timestamp"),
26
+ Index("idx_audit_object", "table_name", "object_id"),
27
+ )
28
+
29
+ id = Column(Integer, primary_key=True, autoincrement=True)
30
+ user_id = Column(
31
+ Integer,
32
+ ForeignKey("admin_users.id", ondelete="SET NULL"),
33
+ nullable=True,
34
+ )
35
+ user_email = Column(String(255))
36
+ action = Column(String(10), nullable=False) # CREATE | UPDATE | DELETE
37
+ model_name = Column(String(255), nullable=False)
38
+ table_name = Column(String(255), nullable=False)
39
+ object_id = Column(String(255), nullable=False)
40
+ object_repr = Column(String(500))
41
+ changes = Column(JSON) # diff (null for CREATE/DELETE)
42
+ full_snapshot = Column(JSON) # full object state at time of action
43
+ ip_address = Column(String(45))
44
+ user_agent = Column(Text)
45
+ timestamp = Column(DateTime(timezone=True), server_default=func.now())
46
+
47
+ def __str__(self) -> str:
48
+ return f"{self.action} {self.model_name}#{self.object_id}"
49
+
50
+ def __repr__(self) -> str:
51
+ return (
52
+ f"<AuditLog {self.action} {self.model_name}#{self.object_id}>"
53
+ )
@@ -0,0 +1,58 @@
1
+ """SQLAlchemy audit logger — persists audit events to the database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi_admin_kit.audit.events import AuditEvent
8
+ from fastapi_admin_kit.audit.logger import AuditLogger
9
+ from fastapi_admin_kit.audit.models import AuditLog
10
+
11
+
12
+ class SqlAlchemyAuditLogger(AuditLogger):
13
+ """Writes AuditEvents to the admin_audit_log table.
14
+
15
+ Buffers AuditLog entries during synchronous SQLAlchemy event callbacks
16
+ (e.g. ``after_flush``) and flushes them to the database asynchronously
17
+ after the main transaction commits. This avoids triggering implicit
18
+ autoflush inside a sync event handler, which would raise
19
+ ``MissingGreenlet`` with async sessions.
20
+ """
21
+
22
+ def __init__(self, session: Any = None) -> None:
23
+ self._session = session
24
+ self._pending: list[AuditLog] = []
25
+
26
+ def log_create(self, event: AuditEvent) -> None:
27
+ self._buffer(event)
28
+
29
+ def log_update(self, event: AuditEvent) -> None:
30
+ self._buffer(event)
31
+
32
+ def log_delete(self, event: AuditEvent) -> None:
33
+ self._buffer(event)
34
+
35
+ def _buffer(self, event: AuditEvent) -> None:
36
+ entry = AuditLog(
37
+ user_id=event.user_id,
38
+ user_email=event.user_email,
39
+ action=event.event_type,
40
+ model_name=event.model_name,
41
+ table_name=event.table_name,
42
+ object_id=event.object_id,
43
+ object_repr=event.object_repr,
44
+ changes=event.changes,
45
+ full_snapshot=event.full_snapshot,
46
+ ip_address=event.ip_address,
47
+ user_agent=event.user_agent,
48
+ )
49
+ self._pending.append(entry)
50
+
51
+ async def flush_pending(self, session: Any) -> None:
52
+ """Write all buffered entries to *session* and clear the buffer."""
53
+ if not self._pending:
54
+ return
55
+ for entry in self._pending:
56
+ session.add(entry)
57
+ self._pending.clear()
58
+ await session.flush()
@@ -0,0 +1,34 @@
1
+ """Auth module — models, session, AuthBackend, PermissionChecker, dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi_admin_kit.auth.backend import AuthBackend, BuiltinAuthBackend
6
+ from fastapi_admin_kit.auth.csrf import (
7
+ auth_redirect_handler,
8
+ require_csrf_token,
9
+ set_csrf_cookie,
10
+ )
11
+ from fastapi_admin_kit.auth.models import (
12
+ AdminPermission,
13
+ AdminRole,
14
+ AdminUser,
15
+ AdminUserPermission,
16
+ )
17
+ from fastapi_admin_kit.auth.session import (
18
+ SessionBackend,
19
+ SignedCookieSessionBackend,
20
+ )
21
+
22
+ __all__ = [
23
+ "AdminPermission",
24
+ "AdminRole",
25
+ "AdminUser",
26
+ "AdminUserPermission",
27
+ "AuthBackend",
28
+ "BuiltinAuthBackend",
29
+ "SessionBackend",
30
+ "SignedCookieSessionBackend",
31
+ "auth_redirect_handler",
32
+ "require_csrf_token",
33
+ "set_csrf_cookie",
34
+ ]
@@ -0,0 +1,95 @@
1
+ """Auth backend — ABC + built-in implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import bcrypt
9
+
10
+ if TYPE_CHECKING:
11
+ from fastapi_admin_kit.auth.protocol import AdminUserProtocol
12
+
13
+
14
+ class _PasswordHasher:
15
+ """Thin wrapper around bcrypt for hash/verify."""
16
+
17
+ @staticmethod
18
+ def hash(password: str) -> str:
19
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
20
+
21
+ @staticmethod
22
+ def verify(password: str, hashed: str) -> bool:
23
+ try:
24
+ return bcrypt.checkpw(password.encode(), hashed.encode())
25
+ except (ValueError, TypeError):
26
+ return False
27
+
28
+
29
+ pwd_context = _PasswordHasher()
30
+
31
+
32
+ class AuthBackend(ABC):
33
+ """Abstract authentication backend — verify credentials & load users."""
34
+
35
+ @abstractmethod
36
+ async def authenticate(
37
+ self, email: str, password: str, session: Any
38
+ ) -> AdminUserProtocol | None:
39
+ """Verify credentials. Return user object if valid, ``None`` otherwise."""
40
+ ...
41
+
42
+ @abstractmethod
43
+ async def get_user(
44
+ self, user_id: int | str, session: Any
45
+ ) -> AdminUserProtocol | None:
46
+ """Load user by PK. Return ``None`` if not found or inactive."""
47
+ ...
48
+
49
+ async def on_logout(self, user_id: int | str | None = None) -> None:
50
+ """Called after a user logs out. Override to perform cleanup."""
51
+ # Default implementation does nothing
52
+ return None
53
+
54
+
55
+ class BuiltinAuthBackend(AuthBackend):
56
+ """Default backend that works with the built-in ``AdminUser`` model."""
57
+
58
+ async def authenticate(
59
+ self, email: str, password: str, session: Any
60
+ ) -> AdminUserProtocol | None:
61
+ from sqlalchemy import select
62
+
63
+ from fastapi_admin_kit.auth.models import AdminUser
64
+
65
+ result = await session.execute(
66
+ select(AdminUser).where(
67
+ AdminUser.email == email, AdminUser.is_active.is_(True)
68
+ )
69
+ )
70
+ user = result.scalar_one_or_none()
71
+
72
+ if not user:
73
+ return None
74
+ if not pwd_context.verify(password, user.hashed_password):
75
+ return None
76
+ return user
77
+
78
+ async def get_user(
79
+ self, user_id: int | str, session: Any
80
+ ) -> AdminUserProtocol | None:
81
+ from sqlalchemy import select
82
+ from sqlalchemy.orm import selectinload
83
+
84
+ from fastapi_admin_kit.auth.models import AdminUser
85
+
86
+ result = await session.execute(
87
+ select(AdminUser)
88
+ .options(selectinload(AdminUser.roles))
89
+ .where(AdminUser.id == user_id, AdminUser.is_active.is_(True))
90
+ )
91
+ return result.scalar_one_or_none()
92
+
93
+ async def on_logout(self, user_id: int | str | None = None) -> None:
94
+ """No-op for built-in backend."""
95
+ return None