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.
Files changed (80) hide show
  1. plain/admin/README.md +260 -0
  2. plain/admin/__init__.py +5 -0
  3. plain/admin/assets/admin/admin.css +108 -0
  4. plain/admin/assets/admin/admin.js +79 -0
  5. plain/admin/assets/admin/chart.js +19 -0
  6. plain/admin/assets/admin/jquery-3.6.1.slim.min.js +2 -0
  7. plain/admin/assets/admin/list.js +57 -0
  8. plain/admin/assets/admin/popper.min.js +5 -0
  9. plain/admin/assets/admin/tippy-bundle.umd.min.js +1 -0
  10. plain/admin/assets/toolbar/toolbar.js +51 -0
  11. plain/admin/cards/__init__.py +10 -0
  12. plain/admin/cards/base.py +86 -0
  13. plain/admin/cards/charts.py +153 -0
  14. plain/admin/cards/tables.py +26 -0
  15. plain/admin/config.py +21 -0
  16. plain/admin/dates.py +254 -0
  17. plain/admin/default_settings.py +4 -0
  18. plain/admin/impersonate/README.md +44 -0
  19. plain/admin/impersonate/__init__.py +3 -0
  20. plain/admin/impersonate/middleware.py +38 -0
  21. plain/admin/impersonate/models.py +0 -0
  22. plain/admin/impersonate/permissions.py +16 -0
  23. plain/admin/impersonate/settings.py +8 -0
  24. plain/admin/impersonate/urls.py +10 -0
  25. plain/admin/impersonate/views.py +23 -0
  26. plain/admin/middleware.py +12 -0
  27. plain/admin/querystats/README.md +191 -0
  28. plain/admin/querystats/__init__.py +3 -0
  29. plain/admin/querystats/core.py +153 -0
  30. plain/admin/querystats/middleware.py +99 -0
  31. plain/admin/querystats/urls.py +9 -0
  32. plain/admin/querystats/views.py +27 -0
  33. plain/admin/templates/admin/base.html +160 -0
  34. plain/admin/templates/admin/cards/base.html +30 -0
  35. plain/admin/templates/admin/cards/card.html +17 -0
  36. plain/admin/templates/admin/cards/chart.html +25 -0
  37. plain/admin/templates/admin/cards/table.html +35 -0
  38. plain/admin/templates/admin/delete.html +17 -0
  39. plain/admin/templates/admin/detail.html +24 -0
  40. plain/admin/templates/admin/form.html +13 -0
  41. plain/admin/templates/admin/index.html +5 -0
  42. plain/admin/templates/admin/list.html +194 -0
  43. plain/admin/templates/admin/page.html +3 -0
  44. plain/admin/templates/admin/search.html +27 -0
  45. plain/admin/templates/admin/values/UUID.html +1 -0
  46. plain/admin/templates/admin/values/bool.html +9 -0
  47. plain/admin/templates/admin/values/datetime.html +1 -0
  48. plain/admin/templates/admin/values/default.html +5 -0
  49. plain/admin/templates/admin/values/dict.html +1 -0
  50. plain/admin/templates/admin/values/get_display.html +1 -0
  51. plain/admin/templates/admin/values/img.html +4 -0
  52. plain/admin/templates/admin/values/list.html +1 -0
  53. plain/admin/templates/admin/values/model.html +15 -0
  54. plain/admin/templates/admin/values/queryset.html +7 -0
  55. plain/admin/templates/elements/admin/Checkbox.html +8 -0
  56. plain/admin/templates/elements/admin/CheckboxField.html +7 -0
  57. plain/admin/templates/elements/admin/FieldErrors.html +5 -0
  58. plain/admin/templates/elements/admin/Input.html +9 -0
  59. plain/admin/templates/elements/admin/InputField.html +5 -0
  60. plain/admin/templates/elements/admin/Label.html +3 -0
  61. plain/admin/templates/elements/admin/Select.html +11 -0
  62. plain/admin/templates/elements/admin/SelectField.html +5 -0
  63. plain/admin/templates/elements/admin/Submit.html +6 -0
  64. plain/admin/templates/querystats/querystats.html +78 -0
  65. plain/admin/templates/querystats/toolbar.html +79 -0
  66. plain/admin/templates/toolbar/toolbar.html +91 -0
  67. plain/admin/templates.py +25 -0
  68. plain/admin/toolbar.py +36 -0
  69. plain/admin/urls.py +45 -0
  70. plain/admin/views/__init__.py +41 -0
  71. plain/admin/views/base.py +140 -0
  72. plain/admin/views/models.py +254 -0
  73. plain/admin/views/objects.py +399 -0
  74. plain/admin/views/registry.py +117 -0
  75. plain/admin/views/types.py +6 -0
  76. plain/admin/views/viewsets.py +54 -0
  77. plain_admin-0.14.1.dist-info/METADATA +275 -0
  78. plain_admin-0.14.1.dist-info/RECORD +80 -0
  79. plain_admin-0.14.1.dist-info/WHEEL +4 -0
  80. plain_admin-0.14.1.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,153 @@
1
+ from collections import defaultdict
2
+
3
+ from plain.admin.dates import DatetimeRangeAliases
4
+ from plain.models import Count
5
+ from plain.models.functions import (
6
+ TruncDate,
7
+ TruncMonth,
8
+ )
9
+
10
+ from .base import Card
11
+
12
+
13
+ class ChartCard(Card):
14
+ template_name = "admin/cards/chart.html"
15
+
16
+ def get_template_context(self):
17
+ context = super().get_template_context()
18
+ context["chart_data"] = self.get_chart_data()
19
+ return context
20
+
21
+ def get_chart_data(self) -> dict:
22
+ raise NotImplementedError
23
+
24
+
25
+ class TrendCard(ChartCard):
26
+ """
27
+ A card that renders a trend chart.
28
+ Primarily intended for use with models, but it can also be customized.
29
+ """
30
+
31
+ model = None
32
+ datetime_field = None
33
+ default_display = DatetimeRangeAliases.SINCE_30_DAYS_AGO
34
+
35
+ displays = DatetimeRangeAliases
36
+
37
+ def get_description(self):
38
+ datetime_range = DatetimeRangeAliases.to_range(self.get_current_display())
39
+ return f"{datetime_range.start} to {datetime_range.end}"
40
+
41
+ def get_current_display(self):
42
+ if s := super().get_current_display():
43
+ return DatetimeRangeAliases.from_value(s)
44
+ return self.default_display
45
+
46
+ def get_trend_data(self) -> list[int | float]:
47
+ if not self.model or not self.datetime_field:
48
+ raise NotImplementedError(
49
+ "model and datetime_field must be set, or get_values must be overridden"
50
+ )
51
+
52
+ datetime_range = DatetimeRangeAliases.to_range(self.get_current_display())
53
+
54
+ filter_kwargs = {f"{self.datetime_field}__range": datetime_range.as_tuple()}
55
+
56
+ if datetime_range.total_days() < 300:
57
+ truncator = TruncDate
58
+ iterator = datetime_range.iter_days
59
+ else:
60
+ truncator = TruncMonth
61
+ iterator = datetime_range.iter_months
62
+
63
+ counts_by_date = (
64
+ self.model.objects.filter(**filter_kwargs)
65
+ .annotate(chart_date=truncator(self.datetime_field))
66
+ .values("chart_date")
67
+ .annotate(chart_date_count=Count("id"))
68
+ )
69
+
70
+ # Will do the zero filling for us on key access
71
+ date_values = defaultdict(int)
72
+
73
+ for row in counts_by_date:
74
+ date_values[row["chart_date"]] = row["chart_date_count"]
75
+
76
+ return {date.strftime("%Y-%m-%d"): date_values[date] for date in iterator()}
77
+
78
+ def get_chart_data(self) -> dict:
79
+ data = self.get_trend_data()
80
+ trend_labels = list(data.keys())
81
+ trend_data = list(data.values())
82
+
83
+ def calculate_trend_line(data):
84
+ """
85
+ Calculate a trend line using basic linear regression.
86
+ :param data: A list of numeric values representing the y-axis.
87
+ :return: A list of trend line values (same length as data).
88
+ """
89
+ if not data or len(data) < 2:
90
+ return (
91
+ data # Return the data as-is if not enough points for a trend line
92
+ )
93
+
94
+ n = len(data)
95
+ x = list(range(n))
96
+ y = data
97
+
98
+ # Calculate the means of x and y
99
+ x_mean = sum(x) / n
100
+ y_mean = sum(y) / n
101
+
102
+ # Calculate the slope (m) and y-intercept (b) of the line: y = mx + b
103
+ numerator = sum((x[i] - x_mean) * (y[i] - y_mean) for i in range(n))
104
+ denominator = sum((x[i] - x_mean) ** 2 for i in range(n))
105
+ slope = numerator / denominator if denominator != 0 else 0
106
+ intercept = y_mean - slope * x_mean
107
+
108
+ # Calculate the trend line values
109
+ trend = [slope * xi + intercept for xi in x]
110
+
111
+ # if it's all zeros, return nothing
112
+ if all(v == 0 for v in trend):
113
+ return []
114
+
115
+ return trend
116
+
117
+ return {
118
+ "type": "bar",
119
+ "data": {
120
+ "labels": trend_labels,
121
+ "datasets": [
122
+ {
123
+ "data": trend_data,
124
+ },
125
+ {
126
+ "data": calculate_trend_line(trend_data),
127
+ "type": "line",
128
+ "borderColor": "rgba(0, 0, 0, 0.3)",
129
+ "borderWidth": 2,
130
+ "fill": False,
131
+ "pointRadius": 0, # Optional: Hide points
132
+ },
133
+ ],
134
+ },
135
+ # Hide the label
136
+ # "options": {"legend": {"display": False}},
137
+ # Hide the scales
138
+ "options": {
139
+ "plugins": {"legend": {"display": False}},
140
+ "scales": {
141
+ "x": {
142
+ "display": False,
143
+ },
144
+ "y": {
145
+ "suggestedMin": 0,
146
+ },
147
+ },
148
+ "maintainAspectRatio": False,
149
+ "elements": {
150
+ "bar": {"borderRadius": "3", "backgroundColor": "rgb(28, 25, 23)"}
151
+ },
152
+ },
153
+ }
@@ -0,0 +1,26 @@
1
+ from .base import Card
2
+
3
+
4
+ class TableCard(Card):
5
+ template_name = "admin/cards/table.html"
6
+ size = Card.Sizes.FULL
7
+
8
+ headers = []
9
+ rows = []
10
+ footers = []
11
+
12
+ def get_template_context(self):
13
+ context = super().get_template_context()
14
+ context["headers"] = self.get_headers()
15
+ context["rows"] = self.get_rows()
16
+ context["footers"] = self.get_footers()
17
+ return context
18
+
19
+ def get_headers(self):
20
+ return self.headers.copy()
21
+
22
+ def get_rows(self):
23
+ return self.rows.copy()
24
+
25
+ def get_footers(self):
26
+ return self.footers.copy()
plain/admin/config.py ADDED
@@ -0,0 +1,21 @@
1
+ from importlib import import_module
2
+ from importlib.util import find_spec
3
+
4
+ from plain.packages import PackageConfig, packages
5
+
6
+
7
+ class Config(PackageConfig):
8
+ name = "plain.admin"
9
+ label = "plainadmin"
10
+
11
+ def ready(self):
12
+ def _import_if_exists(module_name):
13
+ if find_spec(module_name):
14
+ import_module(module_name)
15
+
16
+ # Trigger register calls to fire by importing the modules
17
+ for package_config in packages.get_package_configs():
18
+ _import_if_exists(f"{package_config.name}.admin")
19
+
20
+ # Also trigger for the root app/admin.py module
21
+ _import_if_exists("app.admin")
plain/admin/dates.py ADDED
@@ -0,0 +1,254 @@
1
+ import datetime
2
+ from calendar import monthrange
3
+ from enum import Enum
4
+
5
+ from plain.utils import timezone
6
+
7
+
8
+ class DatetimeRangeAliases(Enum):
9
+ TODAY = "Today"
10
+ THIS_WEEK = "This Week"
11
+ THIS_WEEK_TO_DATE = "This Week-to-date"
12
+ THIS_MONTH = "This Month"
13
+ THIS_MONTH_TO_DATE = "This Month-to-date"
14
+ THIS_QUARTER = "This Quarter"
15
+ THIS_QUARTER_TO_DATE = "This Quarter-to-date"
16
+ THIS_YEAR = "This Year"
17
+ THIS_YEAR_TO_DATE = "This Year-to-date"
18
+ LAST_WEEK = "Last Week"
19
+ LAST_WEEK_TO_DATE = "Last Week-to-date"
20
+ LAST_MONTH = "Last Month"
21
+ LAST_MONTH_TO_DATE = "Last Month-to-date"
22
+ LAST_QUARTER = "Last Quarter"
23
+ LAST_QUARTER_TO_DATE = "Last Quarter-to-date"
24
+ LAST_YEAR = "Last Year"
25
+ LAST_YEAR_TO_DATE = "Last Year-to-date"
26
+ SINCE_30_DAYS_AGO = "Since 30 Days Ago"
27
+ SINCE_60_DAYS_AGO = "Since 60 Days Ago"
28
+ SINCE_90_DAYS_AGO = "Since 90 Days Ago"
29
+ SINCE_365_DAYS_AGO = "Since 365 Days Ago"
30
+ NEXT_WEEK = "Next Week"
31
+ NEXT_4_WEEKS = "Next 4 Weeks"
32
+ NEXT_MONTH = "Next Month"
33
+ NEXT_QUARTER = "Next Quarter"
34
+ NEXT_YEAR = "Next Year"
35
+
36
+ # TODO doesn't include anything less than a day...
37
+ # ex. SINCE_1_HOUR_AGO = "Since 1 Hour Ago"
38
+
39
+ def __str__(self):
40
+ return self.value
41
+
42
+ @classmethod
43
+ def from_value(cls, value):
44
+ for member in cls:
45
+ if member.value == value:
46
+ return member
47
+ raise ValueError(f"{value} is not a valid value for {cls.__name__}")
48
+
49
+ @classmethod
50
+ def to_range(cls, value: str) -> tuple[datetime.datetime, datetime.datetime]:
51
+ now = timezone.localtime()
52
+ start_of_today = now.replace(hour=0, minute=0, second=0, microsecond=0)
53
+ start_of_week = start_of_today - datetime.timedelta(
54
+ days=start_of_today.weekday()
55
+ )
56
+ start_of_month = start_of_today.replace(day=1)
57
+ start_of_quarter = start_of_today.replace(
58
+ month=((start_of_today.month - 1) // 3) * 3 + 1, day=1
59
+ )
60
+ start_of_year = start_of_today.replace(month=1, day=1)
61
+
62
+ def end_of_day(dt):
63
+ return dt.replace(hour=23, minute=59, second=59, microsecond=999999)
64
+
65
+ def end_of_month(dt):
66
+ last_day = monthrange(dt.year, dt.month)[1]
67
+ return end_of_day(dt.replace(day=last_day))
68
+
69
+ def end_of_quarter(dt):
70
+ end_month = ((dt.month - 1) // 3 + 1) * 3
71
+ return end_of_month(dt.replace(month=end_month))
72
+
73
+ def end_of_year(dt):
74
+ return end_of_month(dt.replace(month=12))
75
+
76
+ if value == cls.TODAY:
77
+ return DatetimeRange(start_of_today, end_of_day(now))
78
+ if value == cls.THIS_WEEK:
79
+ return DatetimeRange(
80
+ start_of_week, end_of_day(start_of_week + datetime.timedelta(days=6))
81
+ )
82
+ if value == cls.THIS_WEEK_TO_DATE:
83
+ return DatetimeRange(start_of_week, now)
84
+ if value == cls.THIS_MONTH:
85
+ return DatetimeRange(start_of_month, end_of_month(start_of_month))
86
+ if value == cls.THIS_MONTH_TO_DATE:
87
+ return DatetimeRange(start_of_month, now)
88
+ if value == cls.THIS_QUARTER:
89
+ return DatetimeRange(start_of_quarter, end_of_quarter(start_of_quarter))
90
+ if value == cls.THIS_QUARTER_TO_DATE:
91
+ return DatetimeRange(start_of_quarter, now)
92
+ if value == cls.THIS_YEAR:
93
+ return DatetimeRange(start_of_year, end_of_year(start_of_year))
94
+ if value == cls.THIS_YEAR_TO_DATE:
95
+ return DatetimeRange(start_of_year, now)
96
+ if value == cls.LAST_WEEK:
97
+ last_week_start = start_of_week - datetime.timedelta(days=7)
98
+ return DatetimeRange(
99
+ last_week_start,
100
+ end_of_day(last_week_start + datetime.timedelta(days=6)),
101
+ )
102
+ if value == cls.LAST_WEEK_TO_DATE:
103
+ return DatetimeRange(start_of_week - datetime.timedelta(days=7), now)
104
+ if value == cls.LAST_MONTH:
105
+ last_month = (start_of_month - datetime.timedelta(days=1)).replace(day=1)
106
+ return DatetimeRange(last_month, end_of_month(last_month))
107
+ if value == cls.LAST_MONTH_TO_DATE:
108
+ last_month = (start_of_month - datetime.timedelta(days=1)).replace(day=1)
109
+ return DatetimeRange(last_month, now)
110
+ if value == cls.LAST_QUARTER:
111
+ last_quarter = (start_of_quarter - datetime.timedelta(days=1)).replace(
112
+ day=1
113
+ )
114
+ return DatetimeRange(last_quarter, end_of_quarter(last_quarter))
115
+ if value == cls.LAST_QUARTER_TO_DATE:
116
+ last_quarter = (start_of_quarter - datetime.timedelta(days=1)).replace(
117
+ day=1
118
+ )
119
+ return DatetimeRange(last_quarter, now)
120
+ if value == cls.LAST_YEAR:
121
+ last_year = start_of_year.replace(year=start_of_year.year - 1)
122
+ return DatetimeRange(last_year, end_of_year(last_year))
123
+ if value == cls.LAST_YEAR_TO_DATE:
124
+ last_year = start_of_year.replace(year=start_of_year.year - 1)
125
+ return DatetimeRange(last_year, now)
126
+ if value == cls.SINCE_30_DAYS_AGO:
127
+ return DatetimeRange(now - datetime.timedelta(days=30), now)
128
+ if value == cls.SINCE_60_DAYS_AGO:
129
+ return DatetimeRange(now - datetime.timedelta(days=60), now)
130
+ if value == cls.SINCE_90_DAYS_AGO:
131
+ return DatetimeRange(now - datetime.timedelta(days=90), now)
132
+ if value == cls.SINCE_365_DAYS_AGO:
133
+ return DatetimeRange(now - datetime.timedelta(days=365), now)
134
+ if value == cls.NEXT_WEEK:
135
+ next_week_start = start_of_week + datetime.timedelta(days=7)
136
+ return DatetimeRange(
137
+ next_week_start,
138
+ end_of_day(next_week_start + datetime.timedelta(days=6)),
139
+ )
140
+ if value == cls.NEXT_4_WEEKS:
141
+ return DatetimeRange(now, end_of_day(now + datetime.timedelta(days=28)))
142
+ if value == cls.NEXT_MONTH:
143
+ next_month = (start_of_month + datetime.timedelta(days=31)).replace(day=1)
144
+ return DatetimeRange(next_month, end_of_month(next_month))
145
+ if value == cls.NEXT_QUARTER:
146
+ next_quarter = (start_of_quarter + datetime.timedelta(days=90)).replace(
147
+ day=1
148
+ )
149
+ return DatetimeRange(next_quarter, end_of_quarter(next_quarter))
150
+ if value == cls.NEXT_YEAR:
151
+ next_year = start_of_year.replace(year=start_of_year.year + 1)
152
+ return DatetimeRange(next_year, end_of_year(next_year))
153
+ raise ValueError(f"Invalid range: {value}")
154
+
155
+
156
+ class DatetimeRange:
157
+ def __init__(self, start, end):
158
+ self.start = start
159
+ self.end = end
160
+
161
+ if isinstance(self.start, str) and self.start:
162
+ self.start = datetime.datetime.fromisoformat(self.start)
163
+
164
+ if isinstance(self.end, str) and self.end:
165
+ self.end = datetime.datetime.fromisoformat(self.end)
166
+
167
+ if isinstance(self.start, datetime.date) and not isinstance(
168
+ self.start, datetime.datetime
169
+ ):
170
+ self.start = timezone.localtime().replace(
171
+ year=self.start.year, month=self.start.month, day=self.start.day
172
+ )
173
+
174
+ if isinstance(self.end, datetime.date) and not isinstance(
175
+ self.start, datetime.datetime
176
+ ):
177
+ self.end = timezone.localtime().replace(
178
+ year=self.end.year, month=self.end.month, day=self.end.day
179
+ )
180
+
181
+ def as_tuple(self):
182
+ return (self.start, self.end)
183
+
184
+ def total_days(self):
185
+ return (self.end - self.start).days
186
+
187
+ def iter_days(self):
188
+ """Yields each day in the range."""
189
+ return iter(
190
+ self.start.date() + datetime.timedelta(days=i)
191
+ for i in range(0, self.total_days())
192
+ )
193
+
194
+ def iter_weeks(self):
195
+ """Yields the start of each week in the range."""
196
+ current = self.start - datetime.timedelta(days=self.start.weekday())
197
+ current = current.replace(hour=0, minute=0, second=0, microsecond=0)
198
+ while current <= self.end:
199
+ next_week = current + datetime.timedelta(weeks=1)
200
+ yield current
201
+ current = next_week
202
+
203
+ def iter_months(self):
204
+ """Yields the start of each month in the range."""
205
+ current = self.start.replace(day=1)
206
+ current = current.replace(hour=0, minute=0, second=0, microsecond=0)
207
+ while current <= self.end:
208
+ if current.month == 12:
209
+ next_month = current.replace(year=current.year + 1, month=1)
210
+ else:
211
+ next_month = current.replace(month=current.month + 1)
212
+ yield current
213
+ current = next_month
214
+
215
+ def iter_quarters(self):
216
+ """Yields the start of each quarter in the range."""
217
+ current = self.start.replace(month=((self.start.month - 1) // 3) * 3 + 1, day=1)
218
+ current = current.replace(hour=0, minute=0, second=0, microsecond=0)
219
+ while current <= self.end:
220
+ next_quarter_month = ((current.month - 1) // 3 + 1) * 3 + 1
221
+ if next_quarter_month > 12:
222
+ next_quarter_month -= 12
223
+ next_year = current.year + 1
224
+ else:
225
+ next_year = current.year
226
+ next_quarter = datetime.datetime(
227
+ next_year, next_quarter_month, 1, tzinfo=current.tzinfo
228
+ )
229
+ yield current
230
+ current = next_quarter
231
+
232
+ def iter_years(self):
233
+ """Yields the start of each year in the range."""
234
+ current = self.start.replace(month=1, day=1)
235
+ current = current.replace(hour=0, minute=0, second=0, microsecond=0)
236
+ while current <= self.end:
237
+ next_year = current.replace(year=current.year + 1)
238
+ yield current
239
+ current = next_year
240
+
241
+ def __repr__(self):
242
+ return f"DatetimeRange({self.start}, {self.end})"
243
+
244
+ def __str__(self):
245
+ return f"{self.start} to {self.end}"
246
+
247
+ def __eq__(self, other):
248
+ return self.start == other.start and self.end == other.end
249
+
250
+ def __hash__(self):
251
+ return hash((self.start, self.end))
252
+
253
+ def __contains__(self, item):
254
+ return self.start <= item <= self.end
@@ -0,0 +1,4 @@
1
+ TOOLBAR_CLASS = "plain.admin.toolbar.Toolbar"
2
+ TOOLBAR_VERSION: str = "dev"
3
+
4
+ QUERYSTATS_IGNORE_URLS: list[str] = ["/assets/.*"]
@@ -0,0 +1,44 @@
1
+ # plain.impersonate
2
+
3
+ See what your users see.
4
+
5
+ A key feature for providing customer support is to be able to view the site through their account.
6
+ With `impersonate` installed, you can impersonate a user by finding them in the Django admin and clicking the "Impersonate" button.
7
+
8
+ ![](/docs/img/impersonate-admin.png)
9
+
10
+ Then with the [admin toolbar](/docs/plain-toolbar/) enabled, you'll get a notice of the impersonation and a button to exit:
11
+
12
+ ![](/docs/img/impersonate-bar.png)
13
+
14
+ ## Installation
15
+
16
+ To impersonate users, you need the app, middleware, and URLs:
17
+
18
+ ```python
19
+ # settings.py
20
+ INSTALLED_PACKAGES = INSTALLED_PACKAGES + [
21
+ "plain.admin.impersonate",
22
+ ]
23
+
24
+ MIDDLEWARE = MIDDLEWARE + [
25
+ "plain.admin.impersonate.ImpersonateMiddleware",
26
+ ]
27
+ ```
28
+
29
+ ```python
30
+ # urls.py
31
+ urlpatterns = [
32
+ # ...
33
+ path("impersonate/", include("plain.admin.impersonate.urls")),
34
+ ]
35
+ ```
36
+
37
+ ## Settings
38
+
39
+ By default, all admin users can impersonate other users.
40
+
41
+ ```python
42
+ # settings.py
43
+ IMPERSONATE_ALLOWED = lambda user: user.is_admin
44
+ ```
@@ -0,0 +1,3 @@
1
+ from .middleware import ImpersonateMiddleware
2
+
3
+ __all__ = ["ImpersonateMiddleware"]
@@ -0,0 +1,38 @@
1
+ from plain.auth import get_user_model
2
+ from plain.http import ResponseForbidden
3
+
4
+ from .permissions import can_be_impersonator, can_impersonate_user
5
+ from .views import IMPERSONATE_KEY
6
+
7
+
8
+ def get_user_by_pk(pk):
9
+ UserModel = get_user_model()
10
+
11
+ try:
12
+ return UserModel.objects.get(pk=pk)
13
+ except UserModel.DoesNotExist:
14
+ return None
15
+
16
+
17
+ class ImpersonateMiddleware:
18
+ def __init__(self, get_response):
19
+ self.get_response = get_response
20
+
21
+ def __call__(self, request):
22
+ if (
23
+ IMPERSONATE_KEY in request.session
24
+ and request.user
25
+ and can_be_impersonator(request.user)
26
+ ):
27
+ user_to_impersonate = get_user_by_pk(request.session[IMPERSONATE_KEY])
28
+ if user_to_impersonate:
29
+ if not can_impersonate_user(request.user, user_to_impersonate):
30
+ # Can't impersonate this user, remove it and show an error
31
+ del request.session[IMPERSONATE_KEY]
32
+ return ResponseForbidden()
33
+
34
+ # Finally, change the request user and keep a reference to the original
35
+ request.impersonator = request.user
36
+ request.user = user_to_impersonate
37
+
38
+ return self.get_response(request)
File without changes
@@ -0,0 +1,16 @@
1
+ from . import settings
2
+
3
+
4
+ def can_be_impersonator(user):
5
+ return settings.IMPERSONATE_ALLOWED(user)
6
+
7
+
8
+ def can_impersonate_user(impersonator, target_user):
9
+ if not can_be_impersonator(impersonator):
10
+ return False
11
+
12
+ # You can't impersonate admin users
13
+ if target_user.is_admin:
14
+ return False
15
+
16
+ return True
@@ -0,0 +1,8 @@
1
+ from plain.runtime import settings
2
+
3
+
4
+ def IMPERSONATE_ALLOWED(user):
5
+ if hasattr(settings, "IMPERSONATE_ALLOWED"):
6
+ return settings.IMPERSONATE_ALLOWED(user)
7
+
8
+ return user.is_admin
@@ -0,0 +1,10 @@
1
+ from plain.urls import path
2
+
3
+ from .views import ImpersonateStartView, ImpersonateStopView
4
+
5
+ default_namespace = "impersonate"
6
+
7
+ urlpatterns = [
8
+ path("stop/", ImpersonateStopView, name="stop"),
9
+ path("start/<pk>/", ImpersonateStartView, name="start"),
10
+ ]
@@ -0,0 +1,23 @@
1
+ from plain.http import ResponseForbidden, ResponseRedirect
2
+ from plain.views import View
3
+
4
+ from .permissions import can_be_impersonator
5
+
6
+ IMPERSONATE_KEY = "impersonate"
7
+
8
+
9
+ class ImpersonateStartView(View):
10
+ def get(self):
11
+ # We *could* already be impersonating, so need to consider that
12
+ impersonator = getattr(self.request, "impersonator", self.request.user)
13
+ if impersonator and can_be_impersonator(impersonator):
14
+ self.request.session[IMPERSONATE_KEY] = self.url_kwargs["pk"]
15
+ return ResponseRedirect(self.request.GET.get("next", "/"))
16
+
17
+ return ResponseForbidden()
18
+
19
+
20
+ class ImpersonateStopView(View):
21
+ def get(self):
22
+ self.request.session.pop(IMPERSONATE_KEY)
23
+ return ResponseRedirect(self.request.GET.get("next", "/"))
@@ -0,0 +1,12 @@
1
+ from .impersonate.middleware import ImpersonateMiddleware
2
+ from .querystats.middleware import QueryStatsMiddleware
3
+
4
+
5
+ class AdminMiddleware:
6
+ """All admin-related middleware in a single class."""
7
+
8
+ def __init__(self, get_response):
9
+ self.get_response = get_response
10
+
11
+ def __call__(self, request):
12
+ return QueryStatsMiddleware(ImpersonateMiddleware(self.get_response))(request)