plain.admin 0.25.0__tar.gz → 0.26.0__tar.gz

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 (94) hide show
  1. {plain_admin-0.25.0 → plain_admin-0.26.0}/.gitignore +2 -0
  2. plain_admin-0.26.0/PKG-INFO +178 -0
  3. plain_admin-0.26.0/plain/admin/README.md +163 -0
  4. plain_admin-0.26.0/plain/admin/default_settings.py +4 -0
  5. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/querystats/core.py +14 -10
  6. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/querystats/middleware.py +32 -28
  7. plain_admin-0.26.0/plain/admin/querystats/views.py +48 -0
  8. plain_admin-0.26.0/plain/admin/templates/querystats/querystats.html +110 -0
  9. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/querystats/toolbar.html +31 -21
  10. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates.py +3 -3
  11. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/toolbar.py +1 -1
  12. {plain_admin-0.25.0 → plain_admin-0.26.0}/pyproject.toml +1 -1
  13. plain_admin-0.25.0/PKG-INFO +0 -316
  14. plain_admin-0.25.0/plain/admin/README.md +0 -301
  15. plain_admin-0.25.0/plain/admin/default_settings.py +0 -4
  16. plain_admin-0.25.0/plain/admin/querystats/views.py +0 -27
  17. plain_admin-0.25.0/plain/admin/templates/querystats/querystats.html +0 -78
  18. {plain_admin-0.25.0 → plain_admin-0.26.0}/LICENSE +0 -0
  19. {plain_admin-0.25.0 → plain_admin-0.26.0}/README.md +0 -0
  20. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/__init__.py +0 -0
  21. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/assets/admin/admin.css +0 -0
  22. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/assets/admin/admin.js +0 -0
  23. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/assets/admin/chart.js +0 -0
  24. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/assets/admin/jquery-3.6.1.slim.min.js +0 -0
  25. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/assets/admin/list.js +0 -0
  26. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/assets/admin/popper.min.js +0 -0
  27. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/assets/admin/tippy-bundle.umd.min.js +0 -0
  28. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/assets/toolbar/toolbar.js +0 -0
  29. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/cards/__init__.py +0 -0
  30. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/cards/base.py +0 -0
  31. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/cards/charts.py +0 -0
  32. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/cards/tables.py +0 -0
  33. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/config.py +0 -0
  34. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/dates.py +0 -0
  35. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/impersonate/README.md +0 -0
  36. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/impersonate/__init__.py +0 -0
  37. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/impersonate/middleware.py +0 -0
  38. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/impersonate/models.py +0 -0
  39. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/impersonate/permissions.py +0 -0
  40. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/impersonate/settings.py +0 -0
  41. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/impersonate/urls.py +0 -0
  42. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/impersonate/views.py +0 -0
  43. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/middleware.py +0 -0
  44. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/querystats/README.md +0 -0
  45. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/querystats/__init__.py +0 -0
  46. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/querystats/urls.py +0 -0
  47. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/base.html +0 -0
  48. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/cards/base.html +0 -0
  49. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/cards/card.html +0 -0
  50. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/cards/chart.html +0 -0
  51. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/cards/table.html +0 -0
  52. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/delete.html +0 -0
  53. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/detail.html +0 -0
  54. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/index.html +0 -0
  55. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/list.html +0 -0
  56. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/page.html +0 -0
  57. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/search.html +0 -0
  58. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/values/UUID.html +0 -0
  59. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/values/bool.html +0 -0
  60. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/values/datetime.html +0 -0
  61. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/values/default.html +0 -0
  62. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/values/dict.html +0 -0
  63. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/values/get_display.html +0 -0
  64. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/values/img.html +0 -0
  65. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/values/list.html +0 -0
  66. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/values/model.html +0 -0
  67. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/admin/values/queryset.html +0 -0
  68. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/Checkbox.html +0 -0
  69. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/CheckboxField.html +0 -0
  70. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/FieldErrors.html +0 -0
  71. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/Help.html +0 -0
  72. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/Input.html +0 -0
  73. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/InputField.html +0 -0
  74. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/Label.html +0 -0
  75. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/Select.html +0 -0
  76. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/SelectField.html +0 -0
  77. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/Submit.html +0 -0
  78. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/Textarea.html +0 -0
  79. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/elements/admin/TextareaField.html +0 -0
  80. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/templates/toolbar/toolbar.html +0 -0
  81. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/urls.py +0 -0
  82. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/views/__init__.py +0 -0
  83. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/views/base.py +0 -0
  84. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/views/models.py +0 -0
  85. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/views/objects.py +0 -0
  86. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/views/registry.py +0 -0
  87. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/views/types.py +0 -0
  88. {plain_admin-0.25.0 → plain_admin-0.26.0}/plain/admin/views/viewsets.py +0 -0
  89. {plain_admin-0.25.0 → plain_admin-0.26.0}/tests/app/settings.py +0 -0
  90. {plain_admin-0.25.0 → plain_admin-0.26.0}/tests/app/urls.py +0 -0
  91. {plain_admin-0.25.0 → plain_admin-0.26.0}/tests/app/users/migrations/0001_initial.py +0 -0
  92. {plain_admin-0.25.0 → plain_admin-0.26.0}/tests/app/users/migrations/__init__.py +0 -0
  93. {plain_admin-0.25.0 → plain_admin-0.26.0}/tests/app/users/models.py +0 -0
  94. {plain_admin-0.25.0 → plain_admin-0.26.0}/tests/test_admin.py +0 -0
@@ -11,3 +11,5 @@ plain*/tests/.plain
11
11
 
12
12
  # Ottobot
13
13
  .aider*
14
+
15
+ /llms-full.txt
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.admin
3
+ Version: 0.26.0
4
+ Summary: Admin dashboard and tools for Plain.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: plain-auth<1.0.0
10
+ Requires-Dist: plain-htmx<1.0.0
11
+ Requires-Dist: plain-tailwind<1.0.0
12
+ Requires-Dist: plain<1.0.0
13
+ Requires-Dist: sqlparse>=0.2.2
14
+ Description-Content-Type: text/markdown
15
+
16
+ # plain.admin
17
+
18
+ **Manage your app with a backend interface.**
19
+
20
+ The Plain Admin provides a combination of built-in views and the flexibility to create your own. You can use it to quickly get visibility into your app's data and to manage it.
21
+
22
+ ![Plain Admin user example](https://assets.plainframework.com/docs/plain-pageviews-user.png)
23
+
24
+ ## Installation
25
+
26
+ Install the `plain.admin` package and its dependencies.
27
+
28
+ ```console
29
+ uv add plain.admin
30
+ ```
31
+
32
+ The admin uses a combination of other Plain packages, most of which you will already have installed. Ultimately, your settings will look something like this:
33
+
34
+ ```python
35
+ # app/settings.py
36
+ INSTALLED_PACKAGES = [
37
+ "plain.models",
38
+ "plain.tailwind",
39
+ "plain.auth",
40
+ "plain.sessions",
41
+ "plain.htmx",
42
+ "plain.admin",
43
+ "plain.elements",
44
+ # other packages...
45
+ ]
46
+
47
+ AUTH_USER_MODEL = "users.User"
48
+ AUTH_LOGIN_URL = "login"
49
+
50
+ MIDDLEWARE = [
51
+ "plain.sessions.middleware.SessionMiddleware",
52
+ "plain.auth.middleware.AuthenticationMiddleware",
53
+ "plain.admin.AdminMiddleware",
54
+ ]
55
+ ```
56
+
57
+ Your User model is expected to have an `is_admin` field (or attribute) for checking who has permission to access the admin.
58
+
59
+ ```python
60
+ # app/users/models.py
61
+ from plain import models
62
+
63
+
64
+ @models.register_model
65
+ class User(models.Model):
66
+ is_admin = models.BooleanField(default=False)
67
+ # other fields...
68
+ ```
69
+
70
+ To make the admin accessible, add the `AdminRouter` to your root URLs.
71
+
72
+ ```python
73
+ # app/urls.py
74
+ from plain.admin.urls import AdminRouter
75
+ from plain.urls import Router, include, path
76
+
77
+ from . import views
78
+
79
+
80
+ class AppRouter(Router):
81
+ namespace = ""
82
+ urls = [
83
+ include("admin/", AdminRouter),
84
+ path("login/", views.LoginView, name="login"),
85
+ path("logout/", LogoutView, name="logout"),
86
+ # other urls...
87
+ ]
88
+
89
+ ```
90
+
91
+ Optionally, you can add the admin toolbar to your base template. The toolbar will appear when `settings.DEBUG` or when `request.user.is_admin` (including in production!).
92
+
93
+ ```html
94
+ <!-- app/templates/base.html -->
95
+ <!DOCTYPE html>
96
+ <html lang="en">
97
+ <head>
98
+ <meta charset="UTF-8">
99
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
100
+ <title>{{ html_title|default("My App") }}</title>
101
+ {% tailwind_css %}
102
+ </head>
103
+ <body>
104
+ {% block content required %}{% endblock %}
105
+
106
+ {% toolbar %}
107
+ </body>
108
+ </html>
109
+ ```
110
+
111
+ ## Admin viewsets
112
+
113
+ The most common use of the admin is to display and manage your `plain.models`. To do this, create a viewset with a set of inner views.
114
+
115
+ ```python
116
+ # app/users/admin.py
117
+ from plain.admin.views import (
118
+ AdminModelDetailView,
119
+ AdminModelListView,
120
+ AdminModelUpdateView,
121
+ AdminViewset,
122
+ register_viewset,
123
+ )
124
+ from plain.models.forms import ModelForm
125
+
126
+ from .models import User
127
+
128
+
129
+ class UserForm(ModelForm):
130
+ class Meta:
131
+ model = User
132
+ fields = ["email"]
133
+
134
+
135
+ @register_viewset
136
+ class UserAdmin(AdminViewset):
137
+ class ListView(AdminModelListView):
138
+ model = User
139
+ fields = [
140
+ "id",
141
+ "email",
142
+ "created_at__date",
143
+ ]
144
+ queryset_order = ["-created_at"]
145
+ search_fields = [
146
+ "email",
147
+ ]
148
+
149
+ class DetailView(AdminModelDetailView):
150
+ model = User
151
+
152
+ class UpdateView(AdminModelUpdateView):
153
+ template_name = "admin/users/user_form.html"
154
+ model = User
155
+ form_class = UserForm
156
+ ```
157
+
158
+ The [`AdminViewset`](./views/viewsets.py) will automatically recognize inner views named `ListView`, `CreateView`, `DetailView`, `UpdateView`, and `DeleteView`. It will interlink these views automatically in the UI and form success URLs. You can define additional views too, but you will need to implement a couple methods to hook them up.
159
+
160
+ ## Admin cards
161
+
162
+ TODO
163
+
164
+ ## Admin forms
165
+
166
+ TODO
167
+
168
+ ## Toolbar
169
+
170
+ TODO
171
+
172
+ ## Impersonate
173
+
174
+ TODO
175
+
176
+ ## Querystats
177
+
178
+ TODO
@@ -0,0 +1,163 @@
1
+ # plain.admin
2
+
3
+ **Manage your app with a backend interface.**
4
+
5
+ The Plain Admin provides a combination of built-in views and the flexibility to create your own. You can use it to quickly get visibility into your app's data and to manage it.
6
+
7
+ ![Plain Admin user example](https://assets.plainframework.com/docs/plain-pageviews-user.png)
8
+
9
+ ## Installation
10
+
11
+ Install the `plain.admin` package and its dependencies.
12
+
13
+ ```console
14
+ uv add plain.admin
15
+ ```
16
+
17
+ The admin uses a combination of other Plain packages, most of which you will already have installed. Ultimately, your settings will look something like this:
18
+
19
+ ```python
20
+ # app/settings.py
21
+ INSTALLED_PACKAGES = [
22
+ "plain.models",
23
+ "plain.tailwind",
24
+ "plain.auth",
25
+ "plain.sessions",
26
+ "plain.htmx",
27
+ "plain.admin",
28
+ "plain.elements",
29
+ # other packages...
30
+ ]
31
+
32
+ AUTH_USER_MODEL = "users.User"
33
+ AUTH_LOGIN_URL = "login"
34
+
35
+ MIDDLEWARE = [
36
+ "plain.sessions.middleware.SessionMiddleware",
37
+ "plain.auth.middleware.AuthenticationMiddleware",
38
+ "plain.admin.AdminMiddleware",
39
+ ]
40
+ ```
41
+
42
+ Your User model is expected to have an `is_admin` field (or attribute) for checking who has permission to access the admin.
43
+
44
+ ```python
45
+ # app/users/models.py
46
+ from plain import models
47
+
48
+
49
+ @models.register_model
50
+ class User(models.Model):
51
+ is_admin = models.BooleanField(default=False)
52
+ # other fields...
53
+ ```
54
+
55
+ To make the admin accessible, add the `AdminRouter` to your root URLs.
56
+
57
+ ```python
58
+ # app/urls.py
59
+ from plain.admin.urls import AdminRouter
60
+ from plain.urls import Router, include, path
61
+
62
+ from . import views
63
+
64
+
65
+ class AppRouter(Router):
66
+ namespace = ""
67
+ urls = [
68
+ include("admin/", AdminRouter),
69
+ path("login/", views.LoginView, name="login"),
70
+ path("logout/", LogoutView, name="logout"),
71
+ # other urls...
72
+ ]
73
+
74
+ ```
75
+
76
+ Optionally, you can add the admin toolbar to your base template. The toolbar will appear when `settings.DEBUG` or when `request.user.is_admin` (including in production!).
77
+
78
+ ```html
79
+ <!-- app/templates/base.html -->
80
+ <!DOCTYPE html>
81
+ <html lang="en">
82
+ <head>
83
+ <meta charset="UTF-8">
84
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
85
+ <title>{{ html_title|default("My App") }}</title>
86
+ {% tailwind_css %}
87
+ </head>
88
+ <body>
89
+ {% block content required %}{% endblock %}
90
+
91
+ {% toolbar %}
92
+ </body>
93
+ </html>
94
+ ```
95
+
96
+ ## Admin viewsets
97
+
98
+ The most common use of the admin is to display and manage your `plain.models`. To do this, create a viewset with a set of inner views.
99
+
100
+ ```python
101
+ # app/users/admin.py
102
+ from plain.admin.views import (
103
+ AdminModelDetailView,
104
+ AdminModelListView,
105
+ AdminModelUpdateView,
106
+ AdminViewset,
107
+ register_viewset,
108
+ )
109
+ from plain.models.forms import ModelForm
110
+
111
+ from .models import User
112
+
113
+
114
+ class UserForm(ModelForm):
115
+ class Meta:
116
+ model = User
117
+ fields = ["email"]
118
+
119
+
120
+ @register_viewset
121
+ class UserAdmin(AdminViewset):
122
+ class ListView(AdminModelListView):
123
+ model = User
124
+ fields = [
125
+ "id",
126
+ "email",
127
+ "created_at__date",
128
+ ]
129
+ queryset_order = ["-created_at"]
130
+ search_fields = [
131
+ "email",
132
+ ]
133
+
134
+ class DetailView(AdminModelDetailView):
135
+ model = User
136
+
137
+ class UpdateView(AdminModelUpdateView):
138
+ template_name = "admin/users/user_form.html"
139
+ model = User
140
+ form_class = UserForm
141
+ ```
142
+
143
+ The [`AdminViewset`](./views/viewsets.py) will automatically recognize inner views named `ListView`, `CreateView`, `DetailView`, `UpdateView`, and `DeleteView`. It will interlink these views automatically in the UI and form success URLs. You can define additional views too, but you will need to implement a couple methods to hook them up.
144
+
145
+ ## Admin cards
146
+
147
+ TODO
148
+
149
+ ## Admin forms
150
+
151
+ TODO
152
+
153
+ ## Toolbar
154
+
155
+ TODO
156
+
157
+ ## Impersonate
158
+
159
+ TODO
160
+
161
+ ## Querystats
162
+
163
+ TODO
@@ -0,0 +1,4 @@
1
+ ADMIN_TOOLBAR_CLASS = "plain.admin.toolbar.Toolbar"
2
+ ADMIN_TOOLBAR_VERSION: str = "dev"
3
+
4
+ ADMIN_QUERYSTATS_IGNORE_URLS: list[str] = ["/assets/.*"]
@@ -8,6 +8,8 @@ from plain.utils.functional import cached_property
8
8
 
9
9
  IGNORE_STACK_FILES = [
10
10
  "threading",
11
+ "concurrent/futures",
12
+ "functools.py",
11
13
  "socketserver",
12
14
  "wsgiref",
13
15
  "gunicorn",
@@ -15,11 +17,8 @@ IGNORE_STACK_FILES = [
15
17
  "sentry_sdk",
16
18
  "querystats/core",
17
19
  "plain/template/base",
18
- "plain/utils/decorators",
19
- "plain/db",
20
- "plain/utils/functional",
21
- "plain/core/servers",
22
- "plain/core/handlers",
20
+ "plain/models",
21
+ "plain/internal",
23
22
  ]
24
23
 
25
24
 
@@ -74,7 +73,7 @@ class QueryStats:
74
73
 
75
74
  # if many, then X times is len(params)
76
75
 
77
- current_query["result"] = result
76
+ # current_query["result"] = result
78
77
 
79
78
  current_query["duration"] = time.monotonic() - start
80
79
 
@@ -126,7 +125,7 @@ class QueryStats:
126
125
  "num_duplicate_queries": self.num_duplicate_queries,
127
126
  }
128
127
 
129
- def as_context_dict(self):
128
+ def as_context_dict(self, request):
130
129
  # If we don't create a dict, the instance of this class
131
130
  # is lost before we can use it in the template
132
131
  for query in self.queries:
@@ -137,10 +136,15 @@ class QueryStats:
137
136
  if duplicates:
138
137
  query["duplicate_count"] = duplicates
139
138
 
140
- summary = self.as_summary_dict()
141
-
142
139
  return {
143
- **summary,
140
+ **self.as_summary_dict(),
141
+ "request": {
142
+ "path": request.path,
143
+ "method": request.method,
144
+ "headers": dict(request.headers),
145
+ "unique_id": request.unique_id,
146
+ },
147
+ "timestamp": time.time(),
144
148
  "total_time_display": self.total_time_display,
145
149
  "queries": self.queries,
146
150
  }
@@ -1,26 +1,19 @@
1
1
  import json
2
2
  import logging
3
3
  import re
4
- import threading
5
4
 
6
- from plain.http import ResponseRedirect
7
5
  from plain.json import PlainJSONEncoder
8
6
  from plain.models import connection
9
7
  from plain.runtime import settings
10
- from plain.urls import reverse
11
8
 
12
9
  from .core import QueryStats
13
10
 
14
11
  try:
15
- try:
16
- import psycopg
17
- except ImportError:
18
- import psycopg2 as psycopg
12
+ import psycopg
19
13
  except ImportError:
20
14
  psycopg = None
21
15
 
22
16
  logger = logging.getLogger(__name__)
23
- _local = threading.local()
24
17
 
25
18
 
26
19
  class QueryStatsJSONEncoder(PlainJSONEncoder):
@@ -28,8 +21,11 @@ class QueryStatsJSONEncoder(PlainJSONEncoder):
28
21
  try:
29
22
  return super().default(obj)
30
23
  except TypeError:
31
- if psycopg and isinstance(obj, psycopg._json.Json):
32
- return obj.adapted
24
+ print(type(obj))
25
+ if psycopg and isinstance(obj, psycopg.types.json.Json):
26
+ return obj.obj
27
+ elif psycopg and isinstance(obj, psycopg.types.json.Jsonb):
28
+ return obj.obj
33
29
  else:
34
30
  raise
35
31
 
@@ -38,7 +34,7 @@ class QueryStatsMiddleware:
38
34
  def __init__(self, get_response):
39
35
  self.get_response = get_response
40
36
  self.ignore_url_patterns = [
41
- re.compile(url) for url in settings.QUERYSTATS_IGNORE_URLS
37
+ re.compile(url) for url in settings.ADMIN_QUERYSTATS_IGNORE_URLS
42
38
  ]
43
39
 
44
40
  def should_ignore_request(self, request):
@@ -49,41 +45,49 @@ class QueryStatsMiddleware:
49
45
  return False
50
46
 
51
47
  def __call__(self, request):
52
- if request.GET.get("querystats") == "disable":
48
+ """
49
+ Enables querystats for the current request.
50
+
51
+ If DEBUG or an admin, then Server-Timing headers are always added to the response.
52
+ Full querystats are only stored in the session if they are manually enabled.
53
+ """
54
+
55
+ if self.should_ignore_request(request):
53
56
  return self.get_response(request)
54
57
 
55
- querystats = QueryStats(
56
- # Only want these if we're getting ready to show it
57
- include_tracebacks=request.GET.get("querystats") == "store"
58
- )
58
+ session_querystats_enabled = "querystats" in request.session
59
59
 
60
+ querystats = QueryStats(include_tracebacks=session_querystats_enabled)
60
61
  with connection.execute_wrapper(querystats):
61
62
  # Have to wrap this first call so it is included in the querystats,
62
63
  # but we don't have to wrap everything else unless we are admin or debug
63
64
  is_admin = self.is_admin_request(request)
64
65
 
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):
66
+ if settings.DEBUG or is_admin:
67
+ with connection.execute_wrapper(querystats):
70
68
  response = self.get_response(request)
71
69
 
72
70
  if settings.DEBUG:
73
71
  # TODO logging settings
74
- logger.debug("Querystats: %s", _local.querystats)
72
+ logger.debug("Querystats: %s", querystats)
75
73
 
76
74
  # Make current querystats available on the current page
77
75
  # by using the server timing API which can be parsed client-side
78
- response.headers["Server-Timing"] = _local.querystats.as_server_timing()
76
+ response.headers["Server-Timing"] = querystats.as_server_timing()
79
77
 
80
- if request.GET.get("querystats") == "store":
81
- request.session["querystats"] = json.dumps(
82
- _local.querystats.as_context_dict(), cls=QueryStatsJSONEncoder
78
+ if session_querystats_enabled and querystats.num_queries > 0:
79
+ request.session["querystats"][request.unique_id] = json.dumps(
80
+ querystats.as_context_dict(request), cls=QueryStatsJSONEncoder
83
81
  )
84
- return ResponseRedirect(reverse("querystats:querystats"))
85
82
 
86
- del _local.querystats
83
+ # Keep 30 requests max, in case it is left on by accident
84
+ if len(request.session["querystats"]) > 30:
85
+ del request.session["querystats"][
86
+ list(request.session["querystats"])[0]
87
+ ]
88
+
89
+ # Did a deeper modification to the session dict...
90
+ request.session.modified = True
87
91
 
88
92
  return response
89
93
 
@@ -0,0 +1,48 @@
1
+ import json
2
+
3
+ from plain.auth.views import AuthViewMixin
4
+ from plain.http import ResponseRedirect
5
+ from plain.views import TemplateView
6
+
7
+
8
+ class QuerystatsView(AuthViewMixin, TemplateView):
9
+ template_name = "querystats/querystats.html"
10
+ admin_required = True
11
+
12
+ def get_template_context(self):
13
+ context = super().get_template_context()
14
+
15
+ querystats = self.request.session.get("querystats", {})
16
+
17
+ for request_id, json_data in querystats.items():
18
+ try:
19
+ querystats[request_id] = json.loads(json_data)
20
+ except json.JSONDecodeError:
21
+ # If decoding fails, remove the entry from the dictionary
22
+ del querystats[request_id]
23
+
24
+ # Order them by timestamp
25
+ querystats = dict(
26
+ sorted(
27
+ querystats.items(),
28
+ key=lambda item: item[1].get("timestamp", ""),
29
+ reverse=True,
30
+ )
31
+ )
32
+
33
+ context["querystats"] = querystats
34
+
35
+ return context
36
+
37
+ def post(self):
38
+ querystats_action = self.request.POST["querystats_action"]
39
+
40
+ if querystats_action == "enable":
41
+ self.request.session.setdefault("querystats", {})
42
+ elif querystats_action == "clear":
43
+ self.request.session["querystats"] = {}
44
+ elif querystats_action == "disable" and "querystats" in self.request.session:
45
+ del self.request.session["querystats"]
46
+
47
+ # Redirect back to the page that submitted the form
48
+ return ResponseRedirect(self.request.POST.get("redirect_url", "."))
@@ -0,0 +1,110 @@
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>Querystats</title>
7
+ {% tailwind_css %}
8
+ </head>
9
+ <body class="bg-stone-950 text-stone-300">
10
+
11
+ <div class="flex items-center justify-between px-6 py-4">
12
+ <h1 class="text-lg font-semibold">Querystats</h1>
13
+ <div class="flex items-center space-x-2">
14
+ <form method="post" action=".">
15
+ {{ csrf_input }}
16
+ <input type="hidden" name="querystats_action" value="clear">
17
+ <button type="submit" class="px-2 py-px text-sm rounded-sm bg-stone-700 text-stone-300 hover:bg-stone-600 cursor-pointer whitespace-nowrap">Clear</button>
18
+ </form>
19
+ <form method="post" action=".">
20
+ {{ csrf_input }}
21
+ <input type="hidden" name="querystats_action" value="disable">
22
+ <button type="submit" class="px-2 py-px text-sm rounded-sm bg-stone-700 text-stone-300 hover:bg-stone-600 cursor-pointer whitespace-nowrap">Disable</button>
23
+ </form>
24
+ </div>
25
+ </div>
26
+
27
+ <div class="space-y-6 px-6 py-4">
28
+ {% for request_id, qs in querystats.items() %}
29
+ <div class="p-3 bg-white/5 rounded">
30
+ <div class="flex justify-between items-center">
31
+ <div>
32
+ <h2 class="font-medium"><span class="font-semibold">{{ qs.request.method }}</span> {{ qs.request.path }}</h2>
33
+ <p class="text-sm text-stone-400">{{ qs.summary }}</p>
34
+ </div>
35
+ <div class=text-xs>
36
+ <p>Request ID <code>{{ qs.request.unique_id }}</code></p>
37
+ <p>Timestamp {{ qs.timestamp }}</p>
38
+ <details>
39
+ <summary>Headers</summary>
40
+ <pre><code>{{ qs.request.get("headers", {})|pprint }}</code></pre>
41
+ </details>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="flex w-full mt-5 overflow-auto rounded-sm">
46
+ {% for query in qs.queries %}
47
+ <a href="#query-{{ loop.index }}"
48
+ {{ loop.cycle('class=\"h-4 bg-amber-300\"', 'class=\"h-4 bg-amber-400\"', 'class="h-4 bg-amber-500"', 'class="h-4 bg-amber-600"')|safe }}
49
+ title="[{{ query.duration_display }}] {{ query.sql_display }}"
50
+ style="width: {{ query.duration / qs.total_time * 100 }}%">
51
+ </a>
52
+ {% endfor %}
53
+ </div>
54
+
55
+ <div class="mt-4 space-y-3 text-xs">
56
+ {% for query in qs.queries %}
57
+ <details id="query-{{ loop.index }}" class="p-2 rounded bg-zinc-800">
58
+ <summary class="truncate">
59
+ <div class="float-right px-2 py-px mb-px ml-2 text-xs rounded-full bg-zinc-700">
60
+ <span>{{ query.duration_display }}</span>
61
+ {% if query.duplicate_count is defined %}
62
+ <span class="text-red-500">&nbsp; duplicated {{ query.duplicate_count }} times</span>
63
+ {% endif %}
64
+
65
+ {#
66
+ <div>many {{ query.many }}</div>
67
+ <div>result {{ query.result }}</div>
68
+ #}
69
+ </div>
70
+ <code class="font-mono">{{ query.sql }}</code>
71
+ </summary>
72
+ <div class="space-y-3 mt-3">
73
+ <div>
74
+ <pre><code class="font-mono whitespace-pre-wrap text-zinc-100">{{ query.sql_display }}</code></pre>
75
+ </div>
76
+ <div class="text-zinc-400">
77
+ <span class="font-medium">Parameters</span>
78
+ <pre><code class="font-mono">{{ query.params|pprint }}</code></pre>
79
+ </div>
80
+ <details>
81
+ <summary>Traceback</summary>
82
+ <pre><code class="block overflow-x-auto font-mono text-xs">{{ query.tb }}</code></pre>
83
+ </details>
84
+ </div>
85
+ </details>
86
+ {% else %}
87
+ <div>No queries...</div>
88
+ {% endfor %}
89
+ </div>
90
+ </div>
91
+
92
+ {% else %}
93
+
94
+ <div class="text-center">
95
+ {% if "querystats" in request.session %}
96
+ <div class="text-stone-500">Querystats are enabled but nothing has been tracked yet.</div>
97
+ {% else %}
98
+ <form method="post" action=".">
99
+ {{ csrf_input }}
100
+ <input type="hidden" name="querystats_action" value="enable">
101
+ <button type="submit" class="px-2 rounded-sm bg-stone-700 text-stone-300 hover:bg-stone-600 cursor-pointer whitespace-nowrap">Enable querystats</button>
102
+ </form>
103
+ {% endif %}
104
+ </div>
105
+
106
+ {% endfor %}
107
+ </div>
108
+
109
+ </body>
110
+ </html>