plain.admin 0.33.2__tar.gz → 0.35.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.33.2 → plain_admin-0.35.0}/.gitignore +1 -2
  2. {plain_admin-0.33.2 → plain_admin-0.35.0}/PKG-INFO +1 -6
  3. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/CHANGELOG.md +22 -0
  4. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/README.md +0 -4
  5. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/assets/toolbar/toolbar.js +33 -0
  6. plain_admin-0.35.0/plain/admin/default_settings.py +2 -0
  7. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/middleware.py +1 -2
  8. plain_admin-0.35.0/plain/admin/templates/toolbar/exception_button.html +5 -0
  9. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/toolbar/toolbar.html +4 -8
  10. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/toolbar.py +10 -6
  11. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/urls.py +0 -2
  12. {plain_admin-0.33.2 → plain_admin-0.35.0}/pyproject.toml +1 -2
  13. plain_admin-0.33.2/plain/admin/default_settings.py +0 -8
  14. plain_admin-0.33.2/plain/admin/querystats/README.md +0 -146
  15. plain_admin-0.33.2/plain/admin/querystats/__init__.py +0 -3
  16. plain_admin-0.33.2/plain/admin/querystats/core.py +0 -155
  17. plain_admin-0.33.2/plain/admin/querystats/middleware.py +0 -102
  18. plain_admin-0.33.2/plain/admin/querystats/urls.py +0 -10
  19. plain_admin-0.33.2/plain/admin/querystats/views.py +0 -74
  20. plain_admin-0.33.2/plain/admin/templates/querystats/querystats.html +0 -144
  21. plain_admin-0.33.2/plain/admin/templates/querystats/toolbar.html +0 -90
  22. plain_admin-0.33.2/plain/admin/templates/toolbar/querystats.html +0 -28
  23. {plain_admin-0.33.2 → plain_admin-0.35.0}/LICENSE +0 -0
  24. {plain_admin-0.33.2 → plain_admin-0.35.0}/README.md +0 -0
  25. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/__init__.py +0 -0
  26. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/assets/admin/admin.css +0 -0
  27. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/assets/admin/admin.js +0 -0
  28. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/assets/admin/list.js +0 -0
  29. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/assets/admin/vendor/chart.js +0 -0
  30. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/assets/admin/vendor/jquery-3.6.1.slim.min.js +0 -0
  31. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/assets/admin/vendor/popper.min.js +0 -0
  32. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/assets/admin/vendor/tippy-bundle.umd.min.js +0 -0
  33. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/cards/__init__.py +0 -0
  34. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/cards/base.py +0 -0
  35. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/cards/charts.py +0 -0
  36. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/cards/tables.py +0 -0
  37. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/config.py +0 -0
  38. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/dates.py +0 -0
  39. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/impersonate/README.md +0 -0
  40. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/impersonate/__init__.py +0 -0
  41. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/impersonate/middleware.py +0 -0
  42. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/impersonate/permissions.py +0 -0
  43. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/impersonate/settings.py +0 -0
  44. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/impersonate/urls.py +0 -0
  45. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/impersonate/views.py +0 -0
  46. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/base.html +0 -0
  47. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/cards/base.html +0 -0
  48. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/cards/card.html +0 -0
  49. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/cards/chart.html +0 -0
  50. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/cards/table.html +0 -0
  51. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/delete.html +0 -0
  52. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/detail.html +0 -0
  53. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/index.html +0 -0
  54. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/list.html +0 -0
  55. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/page.html +0 -0
  56. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/search.html +0 -0
  57. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/values/UUID.html +0 -0
  58. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/values/bool.html +0 -0
  59. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/values/datetime.html +0 -0
  60. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/values/default.html +0 -0
  61. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/values/dict.html +0 -0
  62. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/values/get_display.html +0 -0
  63. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/values/img.html +0 -0
  64. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/values/list.html +0 -0
  65. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/values/model.html +0 -0
  66. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/admin/values/queryset.html +0 -0
  67. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/Checkbox.html +0 -0
  68. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/CheckboxField.html +0 -0
  69. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/FieldErrors.html +0 -0
  70. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/Help.html +0 -0
  71. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/Input.html +0 -0
  72. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/InputField.html +0 -0
  73. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/Label.html +0 -0
  74. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/Select.html +0 -0
  75. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/SelectField.html +0 -0
  76. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/Submit.html +0 -0
  77. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/Textarea.html +0 -0
  78. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/elements/admin/TextareaField.html +0 -0
  79. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/toolbar/exception.html +0 -0
  80. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates/toolbar/request.html +0 -0
  81. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/templates.py +0 -0
  82. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/views/__init__.py +0 -0
  83. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/views/base.py +0 -0
  84. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/views/models.py +0 -0
  85. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/views/objects.py +0 -0
  86. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/views/registry.py +0 -0
  87. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/views/types.py +0 -0
  88. {plain_admin-0.33.2 → plain_admin-0.35.0}/plain/admin/views/viewsets.py +0 -0
  89. {plain_admin-0.33.2 → plain_admin-0.35.0}/tests/app/settings.py +0 -0
  90. {plain_admin-0.33.2 → plain_admin-0.35.0}/tests/app/urls.py +0 -0
  91. {plain_admin-0.33.2 → plain_admin-0.35.0}/tests/app/users/migrations/0001_initial.py +0 -0
  92. {plain_admin-0.33.2 → plain_admin-0.35.0}/tests/app/users/migrations/__init__.py +0 -0
  93. {plain_admin-0.33.2 → plain_admin-0.35.0}/tests/app/users/models.py +0 -0
  94. {plain_admin-0.33.2 → plain_admin-0.35.0}/tests/test_admin.py +0 -0
@@ -4,7 +4,6 @@
4
4
  *.py[co]
5
5
  __pycache__
6
6
  *.DS_Store
7
- .coverage
8
7
 
9
8
  # Test apps
10
9
  plain*/tests/.plain
@@ -17,4 +16,4 @@ plain*/tests/.plain
17
16
  # Plain temp dirs
18
17
  .plain
19
18
 
20
- coverage.xml
19
+ .vscode
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.admin
3
- Version: 0.33.2
3
+ Version: 0.35.0
4
4
  Summary: Admin dashboard and tools for Plain.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -10,7 +10,6 @@ Requires-Dist: plain-auth<1.0.0
10
10
  Requires-Dist: plain-htmx<1.0.0
11
11
  Requires-Dist: plain-tailwind<1.0.0
12
12
  Requires-Dist: plain<1.0.0
13
- Requires-Dist: sqlparse>=0.2.2
14
13
  Description-Content-Type: text/markdown
15
14
 
16
15
  # plain.admin
@@ -203,7 +202,3 @@ TODO
203
202
  ## Impersonate
204
203
 
205
204
  TODO
206
-
207
- ## Querystats
208
-
209
- TODO
@@ -1,5 +1,27 @@
1
1
  # plain-admin changelog
2
2
 
3
+ ## [0.35.0](https://github.com/dropseed/plain/releases/plain-admin@0.35.0) (2025-07-18)
4
+
5
+ ### What's changed
6
+
7
+ - The built-in QueryStats functionality has been completely removed from plain-admin in favor of the new OpenTelemetry-based observability tools in the `plain-observer` package ([b0224d0](https://github.com/dropseed/plain/commit/b0224d0))
8
+ - QueryStats documentation has been removed from the admin README ([97fb69d](https://github.com/dropseed/plain/commit/97fb69d))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - Remove any querystats-related settings like `ADMIN_QUERYSTATS_IGNORE_URLS` from your configuration
13
+
14
+ ## [0.34.0](https://github.com/dropseed/plain/releases/plain-admin@0.34.0) (2025-07-18)
15
+
16
+ ### What's changed
17
+
18
+ - The admin toolbar now automatically expands when an exception occurs, making it easier to immediately see exception details ([55a6eaf](https://github.com/dropseed/plain/commit/55a6eaf))
19
+ - The admin toolbar now remembers your custom height preference when resized, persisting across page reloads and browser sessions ([b8db44b](https://github.com/dropseed/plain/commit/b8db44b))
20
+
21
+ ### Upgrade instructions
22
+
23
+ - No changes required
24
+
3
25
  ## [0.33.2](https://github.com/dropseed/plain/releases/plain-admin@0.33.2) (2025-07-07)
4
26
 
5
27
  ### What's changed
@@ -188,7 +188,3 @@ TODO
188
188
  ## Impersonate
189
189
 
190
190
  TODO
191
-
192
- ## Querystats
193
-
194
- TODO
@@ -43,6 +43,10 @@ const plainToolbar = {
43
43
  document.querySelector("#plaintoolbar-details").classList.add("hidden");
44
44
  localStorage.setItem("plaintoolbar.expanded", "0");
45
45
  },
46
+ expandTemporary: function () {
47
+ this.expanded = true;
48
+ document.querySelector("#plaintoolbar-details").classList.remove("hidden");
49
+ },
46
50
  showTab: function (tabName) {
47
51
  this.expand();
48
52
 
@@ -74,6 +78,15 @@ const plainToolbar = {
74
78
  }
75
79
  localStorage.setItem("plaintoolbar.tab", tabName);
76
80
  },
81
+ resetHeight: () => {
82
+ const content = document.querySelector(
83
+ "#plaintoolbar-details [data-resizer]",
84
+ )?.nextElementSibling;
85
+ if (content) {
86
+ content.style.height = "";
87
+ localStorage.removeItem("plaintoolbar.height");
88
+ }
89
+ },
77
90
  };
78
91
 
79
92
  // Render it hidden immediately if the user has hidden it before
@@ -91,10 +104,28 @@ window.addEventListener("load", () => {
91
104
  if (lastTab) {
92
105
  plainToolbar.showTab(lastTab);
93
106
  }
107
+ // Restore custom height if it was set
108
+ const savedHeight = localStorage.getItem("plaintoolbar.height");
109
+ if (savedHeight) {
110
+ const content = document.querySelector(
111
+ "#plaintoolbar-details [data-resizer]",
112
+ )?.nextElementSibling;
113
+ if (content) {
114
+ content.style.height = savedHeight;
115
+ }
116
+ }
94
117
  } else if (state === "0") {
95
118
  plainToolbar.collapse();
96
119
  }
97
120
  const toolbar = document.querySelector("#plaintoolbar");
121
+ const hasException = toolbar.querySelector('[data-toolbar-tab="Exception"]');
122
+
123
+ if (hasException) {
124
+ plainToolbar.show();
125
+ if (!plainToolbar.expanded) {
126
+ plainToolbar.expandTemporary();
127
+ }
128
+ }
98
129
 
99
130
  for (const tab of toolbar.querySelectorAll("button[data-toolbar-tab]")) {
100
131
  tab.addEventListener("click", () => {
@@ -160,6 +191,8 @@ window.addEventListener("load", () => {
160
191
  isDragging = false;
161
192
  handle.style.cursor = "grab";
162
193
  document.body.style.userSelect = "";
194
+ // Save the new height to localStorage
195
+ localStorage.setItem("plaintoolbar.height", content.style.height);
163
196
  }
164
197
  });
165
198
  }
@@ -0,0 +1,2 @@
1
+ ADMIN_TOOLBAR_CLASS = "plain.admin.toolbar.Toolbar"
2
+ ADMIN_TOOLBAR_VERSION: str = "dev"
@@ -1,5 +1,4 @@
1
1
  from .impersonate.middleware import ImpersonateMiddleware
2
- from .querystats.middleware import QueryStatsMiddleware
3
2
 
4
3
 
5
4
  class AdminMiddleware:
@@ -9,4 +8,4 @@ class AdminMiddleware:
9
8
  self.get_response = get_response
10
9
 
11
10
  def __call__(self, request):
12
- return QueryStatsMiddleware(ImpersonateMiddleware(self.get_response))(request)
11
+ return ImpersonateMiddleware(self.get_response)(request)
@@ -0,0 +1,5 @@
1
+ <button class="cursor-pointer text-amber-500 hover:text-amber-400" type="button" data-toolbar-tab="{{ panel.name }}">
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-4 h-4 bi bi-exclamation-triangle-fill" viewBox="0 0 16 16">
3
+ <path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
4
+ </svg>
5
+ </button>
@@ -58,17 +58,13 @@
58
58
  </div>
59
59
  <button type="button" data-plaintoolbar-expand class="flex-grow cursor-pointer"></button>
60
60
  <div class="flex items-center space-x-4">
61
- {% include "querystats/toolbar.html" %}
62
-
63
61
  <div class="flex items-center space-x-3 transition-all">
64
62
 
65
- {% if toolbar.request_exception() %}
66
- <button class="cursor-pointer text-amber-500 hover:text-amber-400" type="button" data-toolbar-tab="Exception">
67
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-4 h-4 bi bi-exclamation-triangle-fill" viewBox="0 0 16 16">
68
- <path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
69
- </svg>
70
- </button>
63
+ {% for panel in panels %}
64
+ {% if panel.button_template_name %}
65
+ {{ panel.render_button() }}
71
66
  {% endif %}
67
+ {% endfor %}
72
68
 
73
69
  <a href="{{ url('admin:index') }}" class="hover:underline">Admin</a>
74
70
 
@@ -49,6 +49,7 @@ class Toolbar:
49
49
  class ToolbarPanel:
50
50
  name: str
51
51
  template_name: str
52
+ button_template_name: str = ""
52
53
 
53
54
  def __init__(self, request):
54
55
  self.request = request
@@ -63,6 +64,14 @@ class ToolbarPanel:
63
64
  context = self.get_template_context()
64
65
  return mark_safe(template.render(context))
65
66
 
67
+ def render_button(self):
68
+ """Render the toolbar button for the minimized state."""
69
+ if not self.button_template_name:
70
+ return ""
71
+ template = Template(self.button_template_name)
72
+ context = self.get_template_context()
73
+ return mark_safe(template.render(context))
74
+
66
75
 
67
76
  class _ToolbarPanelRegistry:
68
77
  def __init__(self):
@@ -86,6 +95,7 @@ def register_toolbar_panel(panel_class):
86
95
  class _ExceptionToolbarPanel(ToolbarPanel):
87
96
  name = "Exception"
88
97
  template_name = "toolbar/exception.html"
98
+ button_template_name = "toolbar/exception_button.html"
89
99
 
90
100
  def __init__(self, request, exception):
91
101
  super().__init__(request)
@@ -101,9 +111,3 @@ class _ExceptionToolbarPanel(ToolbarPanel):
101
111
  class _RequestToolbarPanel(ToolbarPanel):
102
112
  name = "Request"
103
113
  template_name = "toolbar/request.html"
104
-
105
-
106
- @register_toolbar_panel
107
- class _QuerystatsToolbarPanel(ToolbarPanel):
108
- name = "Queries"
109
- template_name = "toolbar/querystats.html"
@@ -2,7 +2,6 @@ from plain.http import ResponseRedirect
2
2
  from plain.urls import Router, include, path
3
3
 
4
4
  from .impersonate.urls import ImpersonateRouter
5
- from .querystats.urls import QuerystatsRouter
6
5
  from .views.base import AdminView
7
6
  from .views.registry import registry
8
7
 
@@ -36,7 +35,6 @@ class AdminRouter(Router):
36
35
  urls = [
37
36
  path("search/", AdminSearchView, name="search"),
38
37
  include("impersonate/", ImpersonateRouter),
39
- include("querystats/", QuerystatsRouter),
40
38
  include("", registry.get_urls()),
41
39
  path("", AdminIndexView, name="index"),
42
40
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.admin"
3
- version = "0.33.2"
3
+ version = "0.35.0"
4
4
  description = "Admin dashboard and tools for Plain."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  license = "BSD-3-Clause"
@@ -11,7 +11,6 @@ dependencies = [
11
11
  "plain.auth<1.0.0",
12
12
  "plain.htmx<1.0.0",
13
13
  "plain.tailwind<1.0.0",
14
- "sqlparse>=0.2.2",
15
14
  ]
16
15
 
17
16
  [tool.uv]
@@ -1,8 +0,0 @@
1
- ADMIN_TOOLBAR_CLASS = "plain.admin.toolbar.Toolbar"
2
- ADMIN_TOOLBAR_VERSION: str = "dev"
3
-
4
- ADMIN_QUERYSTATS_IGNORE_URLS: list[str] = [
5
- "/assets/.*",
6
- "/admin/querystats/.*",
7
- "/favicon.ico",
8
- ]
@@ -1,146 +0,0 @@
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
- ![Django query stats](https://user-images.githubusercontent.com/649496/213781593-54197bb6-36a8-4c9d-8294-5b43bd86a4c9.png)
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
- # plain.toolbar
73
-
74
- The admin toolbar is enabled for every user who `is_admin`.
75
-
76
- ![Plain admin toolbar](https://user-images.githubusercontent.com/649496/213781915-a2094f54-99b8-4a05-a36e-dee107405229.png)
77
-
78
- ## Installation
79
-
80
- Add `plaintoolbar` to your `INSTALLED_PACKAGES`,
81
- and the `{% toolbar %}` to your base template:
82
-
83
- ```python
84
- # settings.py
85
- INSTALLED_PACKAGES += [
86
- "plaintoolbar",
87
- ]
88
- ```
89
-
90
- ```html
91
- <!-- base.template.html -->
92
- {% load toolbar %}
93
- <!doctype html>
94
- <html lang="en">
95
- <head>
96
- ...
97
- </head>
98
- <body>
99
- {% toolbar %}
100
- ...
101
- </body>
102
- ```
103
-
104
- More specific settings can be found below.
105
-
106
- ## Tailwind CSS
107
-
108
- This package is styled with [Tailwind CSS](https://tailwindcss.com/),
109
- and pairs well with [`plain-tailwind`](https://github.com/plainpackages/plain-tailwind).
110
-
111
- If you are using your own Tailwind implementation,
112
- you can modify the "content" in your Tailwind config to include any Plain packages:
113
-
114
- ```js
115
- // tailwind.config.js
116
- module.exports = {
117
- content: [
118
- // ...
119
- ".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
120
- ],
121
- // ...
122
- }
123
- ```
124
-
125
- If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.
126
-
127
- ## Tailwind CSS
128
-
129
- This package is styled with [Tailwind CSS](https://tailwindcss.com/),
130
- and pairs well with [`plain-tailwind`](https://github.com/plainpackages/plain-tailwind).
131
-
132
- If you are using your own Tailwind implementation,
133
- you can modify the "content" in your Tailwind config to include any Plain packages:
134
-
135
- ```js
136
- // tailwind.config.js
137
- module.exports = {
138
- content: [
139
- // ...
140
- ".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
141
- ],
142
- // ...
143
- }
144
- ```
145
-
146
- If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.
@@ -1,3 +0,0 @@
1
- from .middleware import QueryStatsMiddleware
2
-
3
- __all__ = ["QueryStatsMiddleware"]
@@ -1,155 +0,0 @@
1
- import datetime
2
- import time
3
- import traceback
4
- from collections import Counter
5
- from functools import cached_property
6
-
7
- import sqlparse
8
-
9
- IGNORE_STACK_FILES = [
10
- "threading",
11
- "concurrent/futures",
12
- "functools.py",
13
- "socketserver",
14
- "wsgiref",
15
- "gunicorn",
16
- "whitenoise",
17
- "sentry_sdk",
18
- "querystats/core",
19
- "plain/template/base",
20
- "plain/models",
21
- "plain/internal",
22
- ]
23
-
24
-
25
- def pretty_print_sql(sql):
26
- return sqlparse.format(sql, reindent=True, keyword_case="upper")
27
-
28
-
29
- def get_stack():
30
- return "".join(tidy_stack(traceback.format_stack()))
31
-
32
-
33
- def tidy_stack(stack):
34
- lines = []
35
-
36
- skip_next = False
37
-
38
- for line in stack:
39
- if skip_next:
40
- skip_next = False
41
- continue
42
-
43
- if line.startswith(' File "') and any(
44
- ignore in line for ignore in IGNORE_STACK_FILES
45
- ):
46
- skip_next = True
47
- continue
48
-
49
- lines.append(line)
50
-
51
- return lines
52
-
53
-
54
- class QueryStats:
55
- def __init__(self, include_tracebacks):
56
- self.queries = []
57
- self.include_tracebacks = include_tracebacks
58
-
59
- def __str__(self):
60
- s = f"{self.num_queries} queries in {self.total_time_display}"
61
- if self.duplicate_queries:
62
- s += f" ({self.num_duplicate_queries} duplicates)"
63
- return s
64
-
65
- def __call__(self, execute, sql, params, many, context):
66
- current_query = {"sql": sql, "params": params, "many": many}
67
- start = time.monotonic()
68
-
69
- result = execute(sql, params, many, context)
70
-
71
- if self.include_tracebacks:
72
- current_query["tb"] = get_stack()
73
-
74
- # if many, then X times is len(params)
75
-
76
- # current_query["result"] = result
77
-
78
- current_query["duration"] = time.monotonic() - start
79
-
80
- self.queries.append(current_query)
81
- return result
82
-
83
- @cached_property
84
- def total_time(self):
85
- return sum(q["duration"] for q in self.queries)
86
-
87
- @staticmethod
88
- def get_time_display(seconds):
89
- if seconds < 0.01:
90
- return f"{seconds * 1000:.0f} ms"
91
- return f"{seconds:.2f} seconds"
92
-
93
- @cached_property
94
- def total_time_display(self):
95
- return self.get_time_display(self.total_time)
96
-
97
- @cached_property
98
- def num_queries(self):
99
- return len(self.queries)
100
-
101
- # @cached_property
102
- # def models(self):
103
- # # parse table names from self.queries sql
104
- # table_names = [x for x in [q['sql'].split(' ')[2] for q in self.queries] if x]
105
- # models = connection.introspection.installed_models(table_names)
106
- # return models
107
-
108
- @cached_property
109
- def duplicate_queries(self):
110
- sqls = [q["sql"] for q in self.queries]
111
- duplicates = {k: v for k, v in Counter(sqls).items() if v > 1}
112
- return duplicates
113
-
114
- @cached_property
115
- def num_duplicate_queries(self):
116
- # Count the number of "excess" queries by getting how many there
117
- # are minus the initial one (and potentially only one required)
118
- return sum(self.duplicate_queries.values()) - len(self.duplicate_queries)
119
-
120
- def as_summary_dict(self):
121
- return {
122
- "summary": str(self),
123
- "total_time": self.total_time,
124
- "num_queries": self.num_queries,
125
- "num_duplicate_queries": self.num_duplicate_queries,
126
- }
127
-
128
- def as_context_dict(self, request):
129
- # If we don't create a dict, the instance of this class
130
- # is lost before we can use it in the template
131
- for query in self.queries:
132
- # Add some useful display info
133
- query["duration_display"] = self.get_time_display(query["duration"])
134
- query["sql_display"] = pretty_print_sql(query["sql"])
135
- duplicates = self.duplicate_queries.get(query["sql"], 0)
136
- if duplicates:
137
- query["duplicate_count"] = duplicates
138
-
139
- return {
140
- **self.as_summary_dict(),
141
- "request": {
142
- "path": request.path,
143
- "method": request.method,
144
- "unique_id": request.unique_id,
145
- },
146
- "timestamp": datetime.datetime.now().isoformat(),
147
- "total_time_display": self.total_time_display,
148
- "queries": self.queries,
149
- }
150
-
151
- def as_server_timing(self):
152
- duration = self.total_time * 1000 # put in ms
153
- duration = round(duration, 2)
154
- description = str(self)
155
- return f'querystats;dur={duration};desc="{description}"'
@@ -1,102 +0,0 @@
1
- import json
2
- import logging
3
- import re
4
-
5
- from plain.json import PlainJSONEncoder
6
- from plain.models import db_connection
7
- from plain.runtime import settings
8
-
9
- from .core import QueryStats
10
-
11
- try:
12
- import psycopg
13
- except ImportError:
14
- psycopg = None
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
-
19
- class QueryStatsJSONEncoder(PlainJSONEncoder):
20
- def default(self, obj):
21
- try:
22
- return super().default(obj)
23
- except TypeError:
24
- if psycopg and isinstance(obj, psycopg.types.json.Json):
25
- return obj.obj
26
- elif psycopg and isinstance(obj, psycopg.types.json.Jsonb):
27
- return obj.obj
28
- else:
29
- raise
30
-
31
-
32
- class QueryStatsMiddleware:
33
- def __init__(self, get_response):
34
- self.get_response = get_response
35
- self.ignore_url_patterns = [
36
- re.compile(url) for url in settings.ADMIN_QUERYSTATS_IGNORE_URLS
37
- ]
38
-
39
- def should_ignore_request(self, request):
40
- for url in self.ignore_url_patterns:
41
- if url.match(request.path):
42
- return True
43
-
44
- return False
45
-
46
- def __call__(self, request):
47
- """
48
- Enables querystats for the current request.
49
-
50
- If DEBUG or an admin, then Server-Timing headers are always added to the response.
51
- Full querystats are only stored in the session if they are manually enabled.
52
- """
53
-
54
- if self.should_ignore_request(request):
55
- return self.get_response(request)
56
-
57
- def is_tracking():
58
- return "querystats" in request.session
59
-
60
- querystats = QueryStats(include_tracebacks=is_tracking())
61
-
62
- with db_connection.execute_wrapper(querystats):
63
- is_admin = self.is_admin_request(request)
64
-
65
- if settings.DEBUG or is_admin:
66
- with db_connection.execute_wrapper(querystats):
67
- response = self.get_response(request)
68
-
69
- if settings.DEBUG:
70
- # TODO logging settings
71
- logger.debug("Querystats: %s", querystats)
72
-
73
- # Make current querystats available on the current page
74
- # by using the server timing API which can be parsed client-side
75
- response.headers["Server-Timing"] = querystats.as_server_timing()
76
-
77
- if is_tracking() and querystats.num_queries > 0:
78
- request.session["querystats"][request.unique_id] = json.dumps(
79
- querystats.as_context_dict(request), cls=QueryStatsJSONEncoder
80
- )
81
-
82
- # Keep 30 requests max, in case it is left on by accident
83
- if len(request.session["querystats"]) > 30:
84
- del request.session["querystats"][
85
- list(request.session["querystats"])[0]
86
- ]
87
-
88
- # Did a deeper modification to the session dict...
89
- request.session.modified = True
90
-
91
- return response
92
-
93
- else:
94
- return self.get_response(request)
95
-
96
- @staticmethod
97
- def is_admin_request(request):
98
- if getattr(request, "impersonator", None):
99
- # Support for impersonation (still want the real admin user to see the querystats)
100
- return request.impersonator and request.impersonator.is_admin
101
-
102
- return hasattr(request, "user") and request.user and request.user.is_admin