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,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,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
|
+

|
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
|
+

|
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,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,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)
|