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,42 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ FastAPI Admin Kit — HTMX Configuration
3
+ ═══════════════════════════════════════════════════════════════════════════ */
4
+
5
+ document.addEventListener('DOMContentLoaded', () => {
6
+
7
+ /* ── Default swap style ──────────────────────────────────────────────── */
8
+
9
+ if (typeof htmx !== 'undefined') {
10
+ htmx.config.defaultSwapStyle = 'outerHTML';
11
+ }
12
+
13
+ /* ── CSRF / HTMX header injection ────────────────────────────────────── */
14
+
15
+ document.body.addEventListener('htmx:configRequest', (event) => {
16
+ /* Read CSRF token from meta tag if present */
17
+ const meta = document.querySelector('meta[name="csrf-token"]');
18
+ if (meta) {
19
+ event.detail.headers['X-CSRF-Token'] = meta.getAttribute('content');
20
+ }
21
+
22
+ /* Always send the HTMX marker so the server can distinguish HTMX
23
+ requests from full-page navigations. */
24
+ event.detail.headers['HX-Request'] = 'true';
25
+ });
26
+
27
+ /* ── Show/hide loading bar on HTMX events ────────────────────────────── */
28
+
29
+ const loadingBar = document.getElementById('loading-bar');
30
+ if (loadingBar) {
31
+ document.body.addEventListener('htmx:beforeRequest', () => {
32
+ loadingBar.style.display = 'block';
33
+ });
34
+ document.body.addEventListener('htmx:afterRequest', () => {
35
+ loadingBar.style.display = 'none';
36
+ });
37
+ document.body.addEventListener('htmx:responseError', () => {
38
+ loadingBar.style.display = 'none';
39
+ });
40
+ }
41
+
42
+ });
@@ -0,0 +1,6 @@
1
+ """File storage backends."""
2
+
3
+ from fastapi_admin_kit.storage.base import StorageBackend
4
+ from fastapi_admin_kit.storage.local import LocalStorageBackend
5
+
6
+ __all__ = ["StorageBackend", "LocalStorageBackend"]
@@ -0,0 +1,48 @@
1
+ """Storage backend ABC — defines the interface for file storage implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from abc import ABC, abstractmethod
7
+ from pathlib import PurePosixPath
8
+
9
+ from starlette.datastructures import UploadFile
10
+
11
+
12
+ class StorageBackend(ABC):
13
+ """Abstract base class for file storage backends.
14
+
15
+ Implementations must define ``save``, ``delete``, and ``url``.
16
+ ``sanitize_filename`` is shared across all backends.
17
+ """
18
+
19
+ @abstractmethod
20
+ async def save(self, file: UploadFile, directory: str = "") -> str:
21
+ """Save an uploaded file and return its relative path within storage."""
22
+
23
+ @abstractmethod
24
+ async def delete(self, path: str) -> None:
25
+ """Delete a file at the given relative path."""
26
+
27
+ @abstractmethod
28
+ def url(self, path: str) -> str:
29
+ """Return the public URL for a stored file (relative path)."""
30
+
31
+ def sanitize_filename(self, filename: str) -> str:
32
+ """Sanitize an uploaded filename.
33
+
34
+ Prepends a UUID prefix to prevent collisions and strips path
35
+ separators to avoid directory traversal.
36
+ """
37
+ # Strip any directory components
38
+ name = PurePosixPath(filename).name
39
+ if not name:
40
+ name = "unnamed"
41
+
42
+ # Remove null bytes and path separators
43
+ name = name.replace("\x00", "")
44
+ name = name.replace("/", "_").replace("\\", "_")
45
+
46
+ # Prepend UUID for uniqueness
47
+ prefix = uuid.uuid4().hex[:12]
48
+ return f"{prefix}_{name}"
@@ -0,0 +1,73 @@
1
+ """Local filesystem storage backend — saves files to a local directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from starlette.datastructures import UploadFile
9
+
10
+ from fastapi_admin_kit.storage.base import StorageBackend
11
+
12
+
13
+ class LocalStorageBackend(StorageBackend):
14
+ """Saves uploaded files to a local directory and serves via StaticFiles.
15
+
16
+ Parameters
17
+ ----------
18
+ upload_dir:
19
+ Absolute or relative path where files are stored.
20
+ base_url:
21
+ The URL prefix that maps to ``upload_dir`` (e.g. ``"/uploads"``).
22
+ max_size_mb:
23
+ Maximum allowed file size in megabytes. ``None`` means no limit.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ upload_dir: str | Path = "uploads",
29
+ base_url: str = "/uploads",
30
+ max_size_mb: float | None = None,
31
+ ) -> None:
32
+ self.upload_dir = Path(upload_dir)
33
+ self.base_url = base_url.rstrip("/")
34
+ self.max_size_mb = max_size_mb
35
+
36
+ async def save(self, file: UploadFile, directory: str = "") -> str:
37
+ """Save an uploaded file. Returns the relative path within storage."""
38
+ filename = self.sanitize_filename(file.filename or "unnamed")
39
+ target_dir = self.upload_dir / directory
40
+ target_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+ target_path = target_dir / filename
43
+ content = await file.read()
44
+
45
+ if self.max_size_mb is not None:
46
+ max_bytes = int(self.max_size_mb * 1024 * 1024)
47
+ if len(content) > max_bytes:
48
+ raise ValueError(
49
+ f"File size ({len(content)} bytes) exceeds maximum "
50
+ f"allowed size ({self.max_size_mb} MB)."
51
+ )
52
+
53
+ with open(target_path, "wb") as f:
54
+ f.write(content)
55
+
56
+ # Return path relative to upload_dir, using forward slashes
57
+ if directory:
58
+ return f"{directory}/{filename}"
59
+ return filename
60
+
61
+ async def delete(self, path: str) -> None:
62
+ """Delete a file at the given relative path."""
63
+ target = self.upload_dir / path
64
+ if target.is_file():
65
+ os.remove(target)
66
+
67
+ def url(self, path: str) -> str:
68
+ """Return the public URL for a stored file."""
69
+ return f"{self.base_url}/{path}"
70
+
71
+ def ensure_dir(self) -> None:
72
+ """Create the upload directory if it doesn't exist."""
73
+ self.upload_dir.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,142 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="{{ 'dark' if admin_config.dark_mode_default else 'light' }}"
3
+ data-preset="{{ theme_preset | default('editorial') }}">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>{% block title %}{{ title | default("Admin") }}{% endblock %}</title>
8
+ <meta name="csrf-token" content="{{ get_csrf_token(request) }}">
9
+
10
+ {% if admin_config.favicon_url %}
11
+ <link rel="icon" href="{{ admin_config.favicon_url }}">
12
+ {% endif %}
13
+
14
+ <link rel="preconnect" href="https://fonts.googleapis.com">
15
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
+ {% if theme_font_import_url | default('') %}
17
+ <link href="{{ theme_font_import_url }}" rel="stylesheet">
18
+ {% else %}
19
+ <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
20
+ {% endif %}
21
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&display=block" rel="stylesheet">
22
+
23
+ <link rel="stylesheet" href="/static/css/tokens.css?v={{ static_version }}">
24
+ <link rel="stylesheet" href="/static/css/presets.css?v={{ static_version }}">
25
+ <link rel="stylesheet" href="/static/css/admin.css?v={{ static_version }}">
26
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
27
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
28
+
29
+ <script src="https://unpkg.com/htmx.org@2.0.4"></script>
30
+ <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
31
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
32
+ <script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
33
+
34
+ {% if theme_css | default('') %}
35
+ <style>{{ theme_css }}</style>
36
+ {% endif %}
37
+
38
+ <style>
39
+ :root {
40
+ --primary-500: {{ admin_config.primary_color | default('#10B981') }};
41
+ --primary-600: {{ admin_config.primary_color_dark | default('#059669') }};
42
+ }
43
+ </style>
44
+
45
+ {% set _ui = ui_config | default({}) %}
46
+ {% if _ui.custom_css_url | default('') %}
47
+ <link rel="stylesheet" href="{{ _ui.custom_css_url }}">
48
+ {% endif %}
49
+ {% if _ui.custom_css | default('') %}
50
+ <style>{{ _ui.custom_css }}</style>
51
+ {% endif %}
52
+
53
+ {% block head_extra %}{% endblock %}
54
+
55
+ {% for css_url in extra_css | default([]) %}
56
+ <link rel="stylesheet" href="{{ css_url }}">
57
+ {% endfor %}
58
+ {% for js_url in extra_js | default([]) %}
59
+ <script src="{{ js_url }}"></script>
60
+ {% endfor %}
61
+ {% if extra_css_inline | default('') %}
62
+ <style>{{ extra_css_inline }}</style>
63
+ {% endif %}
64
+ {% if extra_js_inline | default('') %}
65
+ <script>{{ extra_js_inline }}</script>
66
+ {% endif %}
67
+ </head>
68
+ <body x-data="{
69
+ sidebarOpen: false,
70
+ sidebarCollapsed: localStorage.getItem('sidebarCollapsed') === 'true',
71
+ init() {
72
+ if (window.innerWidth < 1024) { this.sidebarCollapsed = true; }
73
+ window.addEventListener('resize', () => {
74
+ if (window.innerWidth < 1024) { this.sidebarCollapsed = true; this.sidebarOpen = false; }
75
+ });
76
+ this.$watch('sidebarCollapsed', val => { localStorage.setItem('sidebarCollapsed', val); });
77
+ },
78
+ toggleMobileSidebar() { this.sidebarOpen = !this.sidebarOpen; }
79
+ }"
80
+ class="font-sans antialiased"
81
+ style="color: var(--text-primary); background-color: var(--surface-base);">
82
+
83
+ <div id="loading-bar" class="htmx-indicator-loading"></div>
84
+
85
+ <div class="admin-shell">
86
+ {% include "partials/topbar.html" %}
87
+
88
+ <div class="admin-body">
89
+ {% include "partials/sidebar.html" %}
90
+
91
+ <div class="admin-content {% set _cw = (ui_config | default({})).content_width | default('default') %}{% if _cw != 'default' %}content--{{ _cw }}{% endif %}"
92
+ id="admin-content">
93
+ {% include "partials/flash_messages.html" %}
94
+
95
+ <div class="admin-content__inner">
96
+ {% block content %}{% endblock %}
97
+ </div>
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ {# Confirm dialog modal #}
103
+ <template x-if="true">
104
+ <div id="confirm-dialog"
105
+ x-data="{ open: false, title: '', message: '', onConfirm: null }"
106
+ x-show="open" x-cloak>
107
+ <div class="modal-backdrop" @click="open = false"></div>
108
+ <div class="modal modal--sm" @click.stop>
109
+ <div class="modal__header">
110
+ <h3 class="modal__title" x-text="title"></h3>
111
+ <button type="button" class="modal__close" @click="open = false">
112
+ {{ icon("x-mark") }}
113
+ </button>
114
+ </div>
115
+ <div class="modal__body">
116
+ <div class="flex items-start gap-3">
117
+ {{ icon("exclamation-triangle", class="flex-shrink-0 mt-0.5", size="24px") }}
118
+ <p x-text="message" style="color: var(--text-secondary);"></p>
119
+ </div>
120
+ </div>
121
+ <div class="modal__footer">
122
+ <button @click="open = false" class="btn btn-secondary btn-sm">Cancel</button>
123
+ <button @click="onConfirm(); open = false" class="btn btn-danger btn-sm">Delete</button>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </template>
128
+
129
+ {% include "partials/command_palette.html" %}
130
+
131
+ {% set _ui2 = ui_config | default({}) %}
132
+ {% if _ui2.custom_js_url | default('') %}
133
+ <script src="{{ _ui2.custom_js_url }}"></script>
134
+ {% endif %}
135
+ {% if _ui2.custom_js | default('') %}
136
+ <script>{{ _ui2.custom_js }}</script>
137
+ {% endif %}
138
+
139
+ <script src="/static/js/htmx-config.js"></script>
140
+ <script src="/static/js/admin.js?v={{ static_version }}"></script>
141
+ </body>
142
+ </html>