plain.admin 0.14.1__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.
- plain/admin/README.md +260 -0
- plain/admin/__init__.py +5 -0
- plain/admin/assets/admin/admin.css +108 -0
- plain/admin/assets/admin/admin.js +79 -0
- plain/admin/assets/admin/chart.js +19 -0
- plain/admin/assets/admin/jquery-3.6.1.slim.min.js +2 -0
- plain/admin/assets/admin/list.js +57 -0
- plain/admin/assets/admin/popper.min.js +5 -0
- plain/admin/assets/admin/tippy-bundle.umd.min.js +1 -0
- plain/admin/assets/toolbar/toolbar.js +51 -0
- plain/admin/cards/__init__.py +10 -0
- plain/admin/cards/base.py +86 -0
- plain/admin/cards/charts.py +153 -0
- plain/admin/cards/tables.py +26 -0
- plain/admin/config.py +21 -0
- plain/admin/dates.py +254 -0
- plain/admin/default_settings.py +4 -0
- plain/admin/impersonate/README.md +44 -0
- plain/admin/impersonate/__init__.py +3 -0
- plain/admin/impersonate/middleware.py +38 -0
- plain/admin/impersonate/models.py +0 -0
- plain/admin/impersonate/permissions.py +16 -0
- plain/admin/impersonate/settings.py +8 -0
- plain/admin/impersonate/urls.py +10 -0
- plain/admin/impersonate/views.py +23 -0
- plain/admin/middleware.py +12 -0
- plain/admin/querystats/README.md +191 -0
- plain/admin/querystats/__init__.py +3 -0
- plain/admin/querystats/core.py +153 -0
- plain/admin/querystats/middleware.py +99 -0
- plain/admin/querystats/urls.py +9 -0
- plain/admin/querystats/views.py +27 -0
- plain/admin/templates/admin/base.html +160 -0
- plain/admin/templates/admin/cards/base.html +30 -0
- plain/admin/templates/admin/cards/card.html +17 -0
- plain/admin/templates/admin/cards/chart.html +25 -0
- plain/admin/templates/admin/cards/table.html +35 -0
- plain/admin/templates/admin/delete.html +17 -0
- plain/admin/templates/admin/detail.html +24 -0
- plain/admin/templates/admin/form.html +13 -0
- plain/admin/templates/admin/index.html +5 -0
- plain/admin/templates/admin/list.html +194 -0
- plain/admin/templates/admin/page.html +3 -0
- plain/admin/templates/admin/search.html +27 -0
- plain/admin/templates/admin/values/UUID.html +1 -0
- plain/admin/templates/admin/values/bool.html +9 -0
- plain/admin/templates/admin/values/datetime.html +1 -0
- plain/admin/templates/admin/values/default.html +5 -0
- plain/admin/templates/admin/values/dict.html +1 -0
- plain/admin/templates/admin/values/get_display.html +1 -0
- plain/admin/templates/admin/values/img.html +4 -0
- plain/admin/templates/admin/values/list.html +1 -0
- plain/admin/templates/admin/values/model.html +15 -0
- plain/admin/templates/admin/values/queryset.html +7 -0
- plain/admin/templates/elements/admin/Checkbox.html +8 -0
- plain/admin/templates/elements/admin/CheckboxField.html +7 -0
- plain/admin/templates/elements/admin/FieldErrors.html +5 -0
- plain/admin/templates/elements/admin/Input.html +9 -0
- plain/admin/templates/elements/admin/InputField.html +5 -0
- plain/admin/templates/elements/admin/Label.html +3 -0
- plain/admin/templates/elements/admin/Select.html +11 -0
- plain/admin/templates/elements/admin/SelectField.html +5 -0
- plain/admin/templates/elements/admin/Submit.html +6 -0
- plain/admin/templates/querystats/querystats.html +78 -0
- plain/admin/templates/querystats/toolbar.html +79 -0
- plain/admin/templates/toolbar/toolbar.html +91 -0
- plain/admin/templates.py +25 -0
- plain/admin/toolbar.py +36 -0
- plain/admin/urls.py +45 -0
- plain/admin/views/__init__.py +41 -0
- plain/admin/views/base.py +140 -0
- plain/admin/views/models.py +254 -0
- plain/admin/views/objects.py +399 -0
- plain/admin/views/registry.py +117 -0
- plain/admin/views/types.py +6 -0
- plain/admin/views/viewsets.py +54 -0
- plain_admin-0.14.1.dist-info/METADATA +275 -0
- plain_admin-0.14.1.dist-info/RECORD +80 -0
- plain_admin-0.14.1.dist-info/WHEEL +4 -0
- plain_admin-0.14.1.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,191 @@
|
|
1
|
+
# plain.querystats
|
2
|
+
|
3
|
+
On-page database query stats in development and production.
|
4
|
+
|
5
|
+
On each page, the query stats will display how many database queries were performed and how long they took.
|
6
|
+
|
7
|
+
[Watch on YouTube](https://www.youtube.com/watch?v=NX8VXxVJm08)
|
8
|
+
|
9
|
+
Clicking the stats in the toolbar will show the full SQL query log with tracebacks and timings.
|
10
|
+
This is even designed to work in production,
|
11
|
+
making it much easier to discover and debug performance issues on production data!
|
12
|
+
|
13
|
+

|
14
|
+
|
15
|
+
It will also point out duplicate queries,
|
16
|
+
which can typically be removed by using `select_related`,
|
17
|
+
`prefetch_related`, or otherwise refactoring your code.
|
18
|
+
|
19
|
+
## Installation
|
20
|
+
|
21
|
+
```python
|
22
|
+
# settings.py
|
23
|
+
INSTALLED_PACKAGES = [
|
24
|
+
# ...
|
25
|
+
"plain.admin.querystats",
|
26
|
+
]
|
27
|
+
|
28
|
+
MIDDLEWARE = [
|
29
|
+
"plain.sessions.middleware.SessionMiddleware",
|
30
|
+
"plain.auth.middleware.AuthenticationMiddleware",
|
31
|
+
|
32
|
+
"plain.admin.querystats.QueryStatsMiddleware",
|
33
|
+
# Put additional middleware below querystats
|
34
|
+
# ...
|
35
|
+
]
|
36
|
+
```
|
37
|
+
|
38
|
+
We strongly recommend using the plain-toolbar along with this,
|
39
|
+
but if you aren't,
|
40
|
+
you can add the querystats to your frontend templates with this include:
|
41
|
+
|
42
|
+
```html
|
43
|
+
{% include "querystats/button.html" %}
|
44
|
+
```
|
45
|
+
|
46
|
+
*Note that you will likely want to surround this with an if `DEBUG` or `is_admin` check.*
|
47
|
+
|
48
|
+
To view querystats you need to send a POST request to `?querystats=store` (i.e. via a `<form>`),
|
49
|
+
and the template include is the easiest way to do that.
|
50
|
+
|
51
|
+
## Tailwind CSS
|
52
|
+
|
53
|
+
This package is styled with [Tailwind CSS](https://tailwindcss.com/),
|
54
|
+
and pairs well with [`plain-tailwind`](https://github.com/plainpackages/plain-tailwind).
|
55
|
+
|
56
|
+
If you are using your own Tailwind implementation,
|
57
|
+
you can modify the "content" in your Tailwind config to include any Plain packages:
|
58
|
+
|
59
|
+
```js
|
60
|
+
// tailwind.config.js
|
61
|
+
module.exports = {
|
62
|
+
content: [
|
63
|
+
// ...
|
64
|
+
".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
|
65
|
+
],
|
66
|
+
// ...
|
67
|
+
}
|
68
|
+
```
|
69
|
+
|
70
|
+
If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.
|
71
|
+
|
72
|
+
|
73
|
+
# plain.toolbar
|
74
|
+
|
75
|
+
The admin toolbar is enabled for every user who `is_admin`.
|
76
|
+
|
77
|
+

|
78
|
+
|
79
|
+
## Installation
|
80
|
+
|
81
|
+
Add `plaintoolbar` to your `INSTALLED_PACKAGES`,
|
82
|
+
and the `{% toolbar %}` to your base template:
|
83
|
+
|
84
|
+
```python
|
85
|
+
# settings.py
|
86
|
+
INSTALLED_PACKAGES += [
|
87
|
+
"plaintoolbar",
|
88
|
+
]
|
89
|
+
```
|
90
|
+
|
91
|
+
```html
|
92
|
+
<!-- base.template.html -->
|
93
|
+
{% load toolbar %}
|
94
|
+
<!doctype html>
|
95
|
+
<html lang="en">
|
96
|
+
<head>
|
97
|
+
...
|
98
|
+
</head>
|
99
|
+
<body>
|
100
|
+
{% toolbar %}
|
101
|
+
...
|
102
|
+
</body>
|
103
|
+
```
|
104
|
+
|
105
|
+
More specific settings can be found below.
|
106
|
+
|
107
|
+
## Tailwind CSS
|
108
|
+
|
109
|
+
This package is styled with [Tailwind CSS](https://tailwindcss.com/),
|
110
|
+
and pairs well with [`plain-tailwind`](https://github.com/plainpackages/plain-tailwind).
|
111
|
+
|
112
|
+
If you are using your own Tailwind implementation,
|
113
|
+
you can modify the "content" in your Tailwind config to include any Plain packages:
|
114
|
+
|
115
|
+
```js
|
116
|
+
// tailwind.config.js
|
117
|
+
module.exports = {
|
118
|
+
content: [
|
119
|
+
// ...
|
120
|
+
".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
|
121
|
+
],
|
122
|
+
// ...
|
123
|
+
}
|
124
|
+
```
|
125
|
+
|
126
|
+
If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.
|
127
|
+
|
128
|
+
|
129
|
+
# plain.requestlog
|
130
|
+
|
131
|
+
The request log stores a local history of HTTP requests and responses during `plain work` (Django runserver).
|
132
|
+
|
133
|
+
The request history will make it easy to see redirects,
|
134
|
+
400 and 500 level errors,
|
135
|
+
form submissions,
|
136
|
+
API calls,
|
137
|
+
webhooks,
|
138
|
+
and more.
|
139
|
+
|
140
|
+
[Watch on YouTube](https://www.youtube.com/watch?v=AwI7Pt5oZnM)
|
141
|
+
|
142
|
+
Requests can be re-submitted by clicking the "replay" button.
|
143
|
+
|
144
|
+
[](https://user-images.githubusercontent.com/649496/213781414-417ad043-de67-4836-9ef1-2b91404336c3.png)
|
145
|
+
|
146
|
+
## Installation
|
147
|
+
|
148
|
+
```python
|
149
|
+
# settings.py
|
150
|
+
INSTALLED_PACKAGES += [
|
151
|
+
"plainrequestlog",
|
152
|
+
]
|
153
|
+
|
154
|
+
MIDDLEWARE = MIDDLEWARE + [
|
155
|
+
# ...
|
156
|
+
"plainrequestlog.RequestLogMiddleware",
|
157
|
+
]
|
158
|
+
```
|
159
|
+
|
160
|
+
The default settings can be customized if needed:
|
161
|
+
|
162
|
+
```python
|
163
|
+
# settings.py
|
164
|
+
DEV_REQUESTS_IGNORE_PATHS = [
|
165
|
+
"/sw.js",
|
166
|
+
"/favicon.ico",
|
167
|
+
"/admin/jsi18n/",
|
168
|
+
]
|
169
|
+
DEV_REQUESTS_MAX = 50
|
170
|
+
```
|
171
|
+
|
172
|
+
## Tailwind CSS
|
173
|
+
|
174
|
+
This package is styled with [Tailwind CSS](https://tailwindcss.com/),
|
175
|
+
and pairs well with [`plain-tailwind`](https://github.com/plainpackages/plain-tailwind).
|
176
|
+
|
177
|
+
If you are using your own Tailwind implementation,
|
178
|
+
you can modify the "content" in your Tailwind config to include any Plain packages:
|
179
|
+
|
180
|
+
```js
|
181
|
+
// tailwind.config.js
|
182
|
+
module.exports = {
|
183
|
+
content: [
|
184
|
+
// ...
|
185
|
+
".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
|
186
|
+
],
|
187
|
+
// ...
|
188
|
+
}
|
189
|
+
```
|
190
|
+
|
191
|
+
If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.
|
@@ -0,0 +1,153 @@
|
|
1
|
+
import time
|
2
|
+
import traceback
|
3
|
+
from collections import Counter
|
4
|
+
|
5
|
+
import sqlparse
|
6
|
+
|
7
|
+
from plain.utils.functional import cached_property
|
8
|
+
|
9
|
+
IGNORE_STACK_FILES = [
|
10
|
+
"threading",
|
11
|
+
"socketserver",
|
12
|
+
"wsgiref",
|
13
|
+
"gunicorn",
|
14
|
+
"whitenoise",
|
15
|
+
"sentry_sdk",
|
16
|
+
"querystats/core",
|
17
|
+
"plain/template/base",
|
18
|
+
"plain/utils/decorators",
|
19
|
+
"plain/utils/deprecation",
|
20
|
+
"plain/db",
|
21
|
+
"plain/utils/functional",
|
22
|
+
"plain/core/servers",
|
23
|
+
"plain/core/handlers",
|
24
|
+
]
|
25
|
+
|
26
|
+
|
27
|
+
def pretty_print_sql(sql):
|
28
|
+
return sqlparse.format(sql, reindent=True, keyword_case="upper")
|
29
|
+
|
30
|
+
|
31
|
+
def get_stack():
|
32
|
+
return "".join(tidy_stack(traceback.format_stack()))
|
33
|
+
|
34
|
+
|
35
|
+
def tidy_stack(stack):
|
36
|
+
lines = []
|
37
|
+
|
38
|
+
skip_next = False
|
39
|
+
|
40
|
+
for line in stack:
|
41
|
+
if skip_next:
|
42
|
+
skip_next = False
|
43
|
+
continue
|
44
|
+
|
45
|
+
if line.startswith(' File "') and any(
|
46
|
+
ignore in line for ignore in IGNORE_STACK_FILES
|
47
|
+
):
|
48
|
+
skip_next = True
|
49
|
+
continue
|
50
|
+
|
51
|
+
lines.append(line)
|
52
|
+
|
53
|
+
return lines
|
54
|
+
|
55
|
+
|
56
|
+
class QueryStats:
|
57
|
+
def __init__(self, include_tracebacks):
|
58
|
+
self.queries = []
|
59
|
+
self.include_tracebacks = include_tracebacks
|
60
|
+
|
61
|
+
def __str__(self):
|
62
|
+
s = f"{self.num_queries} queries in {self.total_time_display}"
|
63
|
+
if self.duplicate_queries:
|
64
|
+
s += f" ({self.num_duplicate_queries} duplicates)"
|
65
|
+
return s
|
66
|
+
|
67
|
+
def __call__(self, execute, sql, params, many, context):
|
68
|
+
current_query = {"sql": sql, "params": params, "many": many}
|
69
|
+
start = time.monotonic()
|
70
|
+
|
71
|
+
result = execute(sql, params, many, context)
|
72
|
+
|
73
|
+
if self.include_tracebacks:
|
74
|
+
current_query["tb"] = get_stack()
|
75
|
+
|
76
|
+
# if many, then X times is len(params)
|
77
|
+
|
78
|
+
current_query["result"] = result
|
79
|
+
|
80
|
+
current_query["duration"] = time.monotonic() - start
|
81
|
+
|
82
|
+
self.queries.append(current_query)
|
83
|
+
return result
|
84
|
+
|
85
|
+
@cached_property
|
86
|
+
def total_time(self):
|
87
|
+
return sum(q["duration"] for q in self.queries)
|
88
|
+
|
89
|
+
@staticmethod
|
90
|
+
def get_time_display(seconds):
|
91
|
+
if seconds < 0.01:
|
92
|
+
return f"{seconds * 1000:.0f} ms"
|
93
|
+
return f"{seconds:.2f} seconds"
|
94
|
+
|
95
|
+
@cached_property
|
96
|
+
def total_time_display(self):
|
97
|
+
return self.get_time_display(self.total_time)
|
98
|
+
|
99
|
+
@cached_property
|
100
|
+
def num_queries(self):
|
101
|
+
return len(self.queries)
|
102
|
+
|
103
|
+
# @cached_property
|
104
|
+
# def models(self):
|
105
|
+
# # parse table names from self.queries sql
|
106
|
+
# table_names = [x for x in [q['sql'].split(' ')[2] for q in self.queries] if x]
|
107
|
+
# models = connection.introspection.installed_models(table_names)
|
108
|
+
# return models
|
109
|
+
|
110
|
+
@cached_property
|
111
|
+
def duplicate_queries(self):
|
112
|
+
sqls = [q["sql"] for q in self.queries]
|
113
|
+
duplicates = {k: v for k, v in Counter(sqls).items() if v > 1}
|
114
|
+
return duplicates
|
115
|
+
|
116
|
+
@cached_property
|
117
|
+
def num_duplicate_queries(self):
|
118
|
+
# Count the number of "excess" queries by getting how many there
|
119
|
+
# are minus the initial one (and potentially only one required)
|
120
|
+
return sum(self.duplicate_queries.values()) - len(self.duplicate_queries)
|
121
|
+
|
122
|
+
def as_summary_dict(self):
|
123
|
+
return {
|
124
|
+
"summary": str(self),
|
125
|
+
"total_time": self.total_time,
|
126
|
+
"num_queries": self.num_queries,
|
127
|
+
"num_duplicate_queries": self.num_duplicate_queries,
|
128
|
+
}
|
129
|
+
|
130
|
+
def as_context_dict(self):
|
131
|
+
# If we don't create a dict, the instance of this class
|
132
|
+
# is lost before we can use it in the template
|
133
|
+
for query in self.queries:
|
134
|
+
# Add some useful display info
|
135
|
+
query["duration_display"] = self.get_time_display(query["duration"])
|
136
|
+
query["sql_display"] = pretty_print_sql(query["sql"])
|
137
|
+
duplicates = self.duplicate_queries.get(query["sql"], 0)
|
138
|
+
if duplicates:
|
139
|
+
query["duplicate_count"] = duplicates
|
140
|
+
|
141
|
+
summary = self.as_summary_dict()
|
142
|
+
|
143
|
+
return {
|
144
|
+
**summary,
|
145
|
+
"total_time_display": self.total_time_display,
|
146
|
+
"queries": self.queries,
|
147
|
+
}
|
148
|
+
|
149
|
+
def as_server_timing(self):
|
150
|
+
duration = self.total_time * 1000 # put in ms
|
151
|
+
duration = round(duration, 2)
|
152
|
+
description = str(self)
|
153
|
+
return f'querystats;dur={duration};desc="{description}"'
|
@@ -0,0 +1,99 @@
|
|
1
|
+
import json
|
2
|
+
import logging
|
3
|
+
import re
|
4
|
+
import threading
|
5
|
+
|
6
|
+
from plain.http import ResponseRedirect
|
7
|
+
from plain.json import PlainJSONEncoder
|
8
|
+
from plain.models import connection
|
9
|
+
from plain.runtime import settings
|
10
|
+
from plain.urls import reverse
|
11
|
+
|
12
|
+
from .core import QueryStats
|
13
|
+
|
14
|
+
try:
|
15
|
+
try:
|
16
|
+
import psycopg
|
17
|
+
except ImportError:
|
18
|
+
import psycopg2 as psycopg
|
19
|
+
except ImportError:
|
20
|
+
psycopg = None
|
21
|
+
|
22
|
+
logger = logging.getLogger(__name__)
|
23
|
+
_local = threading.local()
|
24
|
+
|
25
|
+
|
26
|
+
class QueryStatsJSONEncoder(PlainJSONEncoder):
|
27
|
+
def default(self, obj):
|
28
|
+
try:
|
29
|
+
return super().default(obj)
|
30
|
+
except TypeError:
|
31
|
+
if psycopg and isinstance(obj, psycopg._json.Json):
|
32
|
+
return obj.adapted
|
33
|
+
else:
|
34
|
+
raise
|
35
|
+
|
36
|
+
|
37
|
+
class QueryStatsMiddleware:
|
38
|
+
def __init__(self, get_response):
|
39
|
+
self.get_response = get_response
|
40
|
+
self.ignore_url_patterns = [
|
41
|
+
re.compile(url) for url in settings.QUERYSTATS_IGNORE_URLS
|
42
|
+
]
|
43
|
+
|
44
|
+
def should_ignore_request(self, request):
|
45
|
+
for url in self.ignore_url_patterns:
|
46
|
+
if url.match(request.path):
|
47
|
+
return True
|
48
|
+
|
49
|
+
return False
|
50
|
+
|
51
|
+
def __call__(self, request):
|
52
|
+
if request.GET.get("querystats") == "disable":
|
53
|
+
return self.get_response(request)
|
54
|
+
|
55
|
+
querystats = QueryStats(
|
56
|
+
# Only want these if we're getting ready to show it
|
57
|
+
include_tracebacks=request.GET.get("querystats") == "store"
|
58
|
+
)
|
59
|
+
|
60
|
+
with connection.execute_wrapper(querystats):
|
61
|
+
# Have to wrap this first call so it is included in the querystats,
|
62
|
+
# but we don't have to wrap everything else unless we are admin or debug
|
63
|
+
is_admin = self.is_admin_request(request)
|
64
|
+
|
65
|
+
if (settings.DEBUG or is_admin) and not self.should_ignore_request(request):
|
66
|
+
# Persist it on the thread
|
67
|
+
_local.querystats = querystats
|
68
|
+
|
69
|
+
with connection.execute_wrapper(_local.querystats):
|
70
|
+
response = self.get_response(request)
|
71
|
+
|
72
|
+
if settings.DEBUG:
|
73
|
+
# TODO logging settings
|
74
|
+
logger.debug("Querystats: %s", _local.querystats)
|
75
|
+
|
76
|
+
# Make current querystats available on the current page
|
77
|
+
# by using the server timing API which can be parsed client-side
|
78
|
+
response.headers["Server-Timing"] = _local.querystats.as_server_timing()
|
79
|
+
|
80
|
+
if request.GET.get("querystats") == "store":
|
81
|
+
request.session["querystats"] = json.dumps(
|
82
|
+
_local.querystats.as_context_dict(), cls=QueryStatsJSONEncoder
|
83
|
+
)
|
84
|
+
return ResponseRedirect(reverse("querystats:querystats"))
|
85
|
+
|
86
|
+
del _local.querystats
|
87
|
+
|
88
|
+
return response
|
89
|
+
|
90
|
+
else:
|
91
|
+
return self.get_response(request)
|
92
|
+
|
93
|
+
@staticmethod
|
94
|
+
def is_admin_request(request):
|
95
|
+
if getattr(request, "impersonator", None):
|
96
|
+
# Support for impersonation (still want the real admin user to see the querystats)
|
97
|
+
return request.impersonator and request.impersonator.is_admin
|
98
|
+
|
99
|
+
return hasattr(request, "user") and request.user and request.user.is_admin
|
@@ -0,0 +1,27 @@
|
|
1
|
+
import json
|
2
|
+
|
3
|
+
from plain.auth.views import AuthViewMixin
|
4
|
+
from plain.views import TemplateView
|
5
|
+
|
6
|
+
|
7
|
+
class QuerystatsView(AuthViewMixin, TemplateView):
|
8
|
+
template_name = "querystats/querystats.html"
|
9
|
+
admin_required = True # allow impersonator?
|
10
|
+
|
11
|
+
def get_template_context(self):
|
12
|
+
context = super().get_template_context()
|
13
|
+
|
14
|
+
stored_querystats = self.request.session.get(
|
15
|
+
"querystats"
|
16
|
+
) # Not popping so page can be reloaded
|
17
|
+
if stored_querystats:
|
18
|
+
# dates won't come back as Python dates...
|
19
|
+
stored_querystats = json.loads(stored_querystats)
|
20
|
+
context["querystats"] = stored_querystats
|
21
|
+
|
22
|
+
return context
|
23
|
+
|
24
|
+
def get_querystats(self):
|
25
|
+
from .middleware import _local
|
26
|
+
|
27
|
+
return _local.querystats
|
@@ -0,0 +1,160 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<meta charset="UTF-8">
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6
|
+
<title>
|
7
|
+
{%- if title|default('') -%}
|
8
|
+
{{ title }} - Plain Admin
|
9
|
+
{%- else -%}
|
10
|
+
Plain Admin
|
11
|
+
{%- endif -%}
|
12
|
+
</title>
|
13
|
+
{% tailwind_css %}
|
14
|
+
{% htmx_js %}
|
15
|
+
<link href="{{ asset('admin/admin.css') }}" rel="stylesheet">
|
16
|
+
<script src="{{ asset('admin/jquery-3.6.1.slim.min.js') }}"></script>
|
17
|
+
<script src="{{ asset('admin/chart.js') }}" defer></script>
|
18
|
+
<script src="{{ asset('admin/admin.js') }}" defer></script>
|
19
|
+
<script src="{{ asset('admin/popper.min.js') }}" defer></script>
|
20
|
+
<script src="{{ asset('admin/tippy-bundle.umd.min.js') }}" defer></script>
|
21
|
+
{% block header_scripts %}{% endblock %}
|
22
|
+
</head>
|
23
|
+
<body class="flex min-h-screen bg-stone-950">
|
24
|
+
|
25
|
+
<nav class="fixed top-0 left-0 right-0 h-14 px-4 py-2 flex items-center justify-evenly text-sm text-white/70">
|
26
|
+
<div class="flex items-center space-x-2">
|
27
|
+
<button type="button" data-toggle="#admin-sidebar,#admin-content" class="mr-1 lg:hidden">
|
28
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-5 h-5 bi bi-list" viewBox="0 0 16 16">
|
29
|
+
<path fill-rule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5m0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5"/>
|
30
|
+
</svg>
|
31
|
+
<span class="sr-only">Toggle menu</span>
|
32
|
+
</button>
|
33
|
+
|
34
|
+
<a class="inline-flex items-center text-stone-300" href="{{ url('admin:index') }}">
|
35
|
+
<svg class="w-5 h-5 mr-2" width="160" height="125" viewBox="0 0 160 125" fill="none" xmlns="http://www.w3.org/2000/svg">
|
36
|
+
<rect x="4.78467" y="4.79785" width="150.978" height="115.404" rx="5" stroke="#ffffff" stroke-width="8"/>
|
37
|
+
<path d="M151.762 60.3705C99.2596 39.3233 80.202 66.8232 8.78467 60.3705V116.2H151.762V60.3705Z" fill="#ffffff"/>
|
38
|
+
<path d="M51.104 8.08887H108.179V10.7668C108.179 12.6998 106.612 14.2668 104.679 14.2668H54.604C52.671 14.2668 51.104 12.6998 51.104 10.7668V8.08887Z" fill="#ffffff" stroke="#ffffff"/>
|
39
|
+
</svg>
|
40
|
+
<span class="text-stone-400">Admin</span>
|
41
|
+
</a>
|
42
|
+
|
43
|
+
{#
|
44
|
+
{% for parent in parent_view_classes %}
|
45
|
+
<span class="text-stone-400">/</span>
|
46
|
+
<a class="text-stone-500" href="{{ parent.get_view_url() }}">{{ parent.get_nav_title() }}</a>
|
47
|
+
{% endfor %}
|
48
|
+
<span class="text-stone-400">/</span>
|
49
|
+
<a class="text-stone-600" href="{{ request.path }}">{{ title }}</a>
|
50
|
+
#}
|
51
|
+
</div>
|
52
|
+
|
53
|
+
<form method="GET" action="{{ url('admin:search') }}" class="flex flex-1 justify-center">
|
54
|
+
<div class="relative max-w-xs">
|
55
|
+
<label for="query" class="sr-only">Search</label>
|
56
|
+
<input
|
57
|
+
type="text"
|
58
|
+
name="query"
|
59
|
+
id="query"
|
60
|
+
class="block w-full pr-10 pl-10 placeholder:text-center text-sm border-gray-200/10 text-white rounded-md focus:border-blue-500 focus:ring-blue-500 bg-white/5 py-1"
|
61
|
+
placeholder="Search everything"
|
62
|
+
value="{{ global_search_query|default('') }}"
|
63
|
+
>
|
64
|
+
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
|
65
|
+
<svg class="h-3.5 w-3.5 text-gray-400" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
66
|
+
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"></path>
|
67
|
+
</svg>
|
68
|
+
</div>
|
69
|
+
</div>
|
70
|
+
</form>
|
71
|
+
|
72
|
+
<div class="">
|
73
|
+
<a href="/" class="">Back to app</a>
|
74
|
+
</div>
|
75
|
+
</nav>
|
76
|
+
|
77
|
+
<div class="fixed top-14 bottom-2 left-2 right-2">
|
78
|
+
<aside id="admin-sidebar" data-toggle-class="hidden" class="flex-col bg-stone-950 z-50 border-r border-white/10 lg:border-none justify-between flex-shrink-0 hidden w-52 pl-3.5 pr-5 pt-2 overflow-auto lg:flex absolute top-0 bottom-0">
|
79
|
+
<div class="flex-grow">
|
80
|
+
<div>
|
81
|
+
{% for section, views in admin_registry.get_nav_sections().items() %}
|
82
|
+
<div class="mt-4 text-xs tracking-wide uppercase text-stone-300/90">{{ section }}</div>
|
83
|
+
{% for view in views %}
|
84
|
+
{% set url = view.get_view_url() %}
|
85
|
+
<a
|
86
|
+
{% if url == request.path %}data-active{% endif %}
|
87
|
+
class="data-[active]:bg-white/20 data-[active]:text-white flex items-center px-2 py-1 mt-px -mx-2 text-sm rounded hover:text-stone-300 text-stone-400/80 hover:bg-white/5"
|
88
|
+
href="{{ url }}">
|
89
|
+
{{ view.get_nav_title() }}
|
90
|
+
</a>
|
91
|
+
{% endfor %}
|
92
|
+
{% endfor %}
|
93
|
+
</div>
|
94
|
+
|
95
|
+
<!-- <div class="mt-6">
|
96
|
+
<div class="text-xs tracking-wide text-stone-500">Pinned</div>
|
97
|
+
</div>
|
98
|
+
<div class="mt-6">
|
99
|
+
<div class="text-xs tracking-wide text-stone-500">Recent</div>
|
100
|
+
</div> -->
|
101
|
+
</div>
|
102
|
+
<div class="flex items-center justify-between mt-8 text-sm pb-3 pt-3 text-stone-400 sticky bottom-0 bg-stone-950">
|
103
|
+
<div class="flex items-center truncate">
|
104
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-6 h-6 mr-1.5 bi bi-person-circle" viewBox="0 0 16 16">
|
105
|
+
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
106
|
+
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
107
|
+
</svg>
|
108
|
+
<span class="truncate">
|
109
|
+
{{ request.user }}
|
110
|
+
</span>
|
111
|
+
</div>
|
112
|
+
<a class="ml-2 hover:text-white hover:underline flex-shrink-0" href="{{ url('logout') }}">Log out</a>
|
113
|
+
</div>
|
114
|
+
</aside>
|
115
|
+
|
116
|
+
<div id="admin-content" data-toggle-class="x" class="absolute top-0 bottom-0 right-0 left-0 lg:left-52 bg-stone-900 border border-white/5 text-white rounded-lg overflow-auto">
|
117
|
+
<div class="flex items-center justify-between px-4 lg:px-8 sticky z-10 top-0 bg-stone-900 border-b border-white/10 py-2 lg:py-3">
|
118
|
+
<div>
|
119
|
+
{% block header %}
|
120
|
+
<div class="flex items-center">
|
121
|
+
{% block image %}
|
122
|
+
{% if image %}
|
123
|
+
<img src="{{ image.src }}" alt="{{ image.alt }}" class="h-20 rounded mr-3">
|
124
|
+
{% endif %}
|
125
|
+
{% endblock %}
|
126
|
+
<div class="max-w-prose break-all">
|
127
|
+
<h1 class="text-xl text-white/90">
|
128
|
+
{% block title %}{{ title }}{% endblock %}
|
129
|
+
</h1>
|
130
|
+
{% if description %}<p class="mt-1 text-sm text-gray-500">{{ description }}</p>{% endif %}
|
131
|
+
</div>
|
132
|
+
</div>
|
133
|
+
{% endblock %}
|
134
|
+
</div>
|
135
|
+
<div class="flex space-x-2 text-sm actions">
|
136
|
+
{% block actions %}{% endblock %}
|
137
|
+
{% for link, url in links.items() %}
|
138
|
+
<a href="{{ url }}">{{ link }}</a>
|
139
|
+
{% endfor %}
|
140
|
+
</div>
|
141
|
+
</div>
|
142
|
+
|
143
|
+
{% if cards %}
|
144
|
+
<div class="px-4 mt-5 lg:px-8">
|
145
|
+
<div class="grid grid-cols-1 gap-6 mt-4 sm:grid-cols-2 lg:grid-cols-4">
|
146
|
+
{% for card in cards %}
|
147
|
+
{{ render_card(card)|safe }}
|
148
|
+
{% endfor %}
|
149
|
+
</div>
|
150
|
+
</div>
|
151
|
+
{% endif %}
|
152
|
+
|
153
|
+
<main class="px-4 py-6 lg:px-8 text-white/70">{% block content %}{% endblock %}</main>
|
154
|
+
</div>
|
155
|
+
|
156
|
+
</div>
|
157
|
+
|
158
|
+
{% toolbar %}
|
159
|
+
</body>
|
160
|
+
</html>
|
@@ -0,0 +1,30 @@
|
|
1
|
+
<section
|
2
|
+
id="card-{{ slug }}"
|
3
|
+
hx-target="this"
|
4
|
+
hx-select="#card-{{ slug }}"
|
5
|
+
hx-swap="outerHTML"
|
6
|
+
class="col-span-{{ size.value }}">
|
7
|
+
{% block header %}
|
8
|
+
<header class="flex justify-between items-center">
|
9
|
+
<div>
|
10
|
+
<h2 class="text-sm font-semibold">{{ title }}</h2>
|
11
|
+
{% if description %}<p class="mt-1 text-xs text-gray-500">{{ description }}</p>{% endif %}
|
12
|
+
</div>
|
13
|
+
|
14
|
+
{% if displays %}
|
15
|
+
<select
|
16
|
+
hx-get
|
17
|
+
name="{{ slug }}.display"
|
18
|
+
class="text-sm border-gray-200 rounded-md">
|
19
|
+
<option value="">(Reset to default)</option>
|
20
|
+
{% for display in displays %}
|
21
|
+
<option {% if display == current_display %}selected{% endif %}>{{ display }}</option>
|
22
|
+
{% endfor %}
|
23
|
+
</select>
|
24
|
+
{% endif %}
|
25
|
+
</header>
|
26
|
+
{% endblock %}
|
27
|
+
|
28
|
+
<div>
|
29
|
+
{% block content %}{% endblock %}
|
30
|
+
</section>
|