arthexis 0.1.8__py3-none-any.whl → 0.1.9__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.
Potentially problematic release.
This version of arthexis might be problematic. Click here for more details.
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/METADATA +42 -4
- arthexis-0.1.9.dist-info/RECORD +92 -0
- arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +133 -16
- config/urls.py +65 -6
- core/admin.py +1226 -191
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +158 -3
- core/backends.py +46 -4
- core/entity.py +62 -48
- core/fields.py +6 -1
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1071 -264
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/release.py +27 -20
- core/sigil_builder.py +131 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +129 -10
- core/tasks.py +118 -19
- core/test_system_info.py +22 -0
- core/tests.py +358 -63
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +329 -167
- core/views.py +383 -57
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +159 -284
- nodes/apps.py +9 -15
- nodes/backends.py +53 -0
- nodes/lcd.py +24 -10
- nodes/models.py +375 -178
- nodes/tasks.py +1 -5
- nodes/tests.py +524 -129
- nodes/utils.py +13 -2
- nodes/views.py +66 -23
- ocpp/admin.py +150 -61
- ocpp/apps.py +1 -1
- ocpp/consumers.py +432 -69
- ocpp/evcs.py +25 -8
- ocpp/models.py +408 -68
- ocpp/simulator.py +13 -6
- ocpp/store.py +258 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1198 -135
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +654 -101
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +19 -6
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +759 -40
- pages/urls.py +3 -0
- pages/utils.py +0 -1
- pages/views.py +576 -25
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
- {arthexis-0.1.8.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
pages/views.py
CHANGED
|
@@ -1,33 +1,355 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from pathlib import Path
|
|
3
|
+
import datetime
|
|
4
|
+
import calendar
|
|
5
|
+
import shutil
|
|
6
|
+
import re
|
|
7
|
+
from html import escape
|
|
3
8
|
|
|
4
9
|
from django.conf import settings
|
|
10
|
+
from django.contrib import admin
|
|
11
|
+
from django.contrib import messages
|
|
12
|
+
from django.contrib.admin.views.decorators import staff_member_required
|
|
5
13
|
from django.contrib.auth import get_user_model, login
|
|
6
14
|
from django.contrib.auth.tokens import default_token_generator
|
|
7
15
|
from django.contrib.auth.views import LoginView
|
|
8
16
|
from django import forms
|
|
17
|
+
from django.apps import apps as django_apps
|
|
9
18
|
from utils.sites import get_site
|
|
10
|
-
from django.http import HttpResponse
|
|
19
|
+
from django.http import Http404, HttpResponse
|
|
11
20
|
from django.shortcuts import redirect, render
|
|
12
21
|
from nodes.models import Node
|
|
13
|
-
from django.
|
|
14
|
-
from django.
|
|
22
|
+
from django.template.response import TemplateResponse
|
|
23
|
+
from django.urls import NoReverseMatch, reverse
|
|
24
|
+
from django.utils import timezone
|
|
15
25
|
from django.utils.encoding import force_bytes, force_str
|
|
16
26
|
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
|
17
|
-
from
|
|
27
|
+
from core import mailer, public_wifi
|
|
18
28
|
from django.utils.translation import gettext as _
|
|
19
29
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
|
20
|
-
from
|
|
30
|
+
from django.views.decorators.cache import never_cache
|
|
31
|
+
from django.utils.cache import patch_vary_headers
|
|
32
|
+
from django.core.exceptions import PermissionDenied
|
|
33
|
+
from django.utils.text import slugify
|
|
34
|
+
from django.core.validators import EmailValidator
|
|
35
|
+
from core.models import InviteLead, ClientReport, ClientReportSchedule
|
|
36
|
+
|
|
37
|
+
try: # pragma: no cover - optional dependency guard
|
|
38
|
+
from graphviz import Digraph
|
|
39
|
+
from graphviz.backend import CalledProcessError, ExecutableNotFound
|
|
40
|
+
except ImportError: # pragma: no cover - handled gracefully in views
|
|
41
|
+
Digraph = None
|
|
42
|
+
CalledProcessError = ExecutableNotFound = None
|
|
21
43
|
|
|
22
44
|
import markdown
|
|
23
45
|
from pages.utils import landing
|
|
46
|
+
from core.liveupdate import live_update
|
|
24
47
|
from .models import Module
|
|
25
48
|
|
|
26
49
|
|
|
27
50
|
logger = logging.getLogger(__name__)
|
|
28
51
|
|
|
29
52
|
|
|
53
|
+
def _get_registered_models(app_label: str):
|
|
54
|
+
"""Return admin-registered models for the given app label."""
|
|
55
|
+
|
|
56
|
+
registered = [
|
|
57
|
+
model for model in admin.site._registry if model._meta.app_label == app_label
|
|
58
|
+
]
|
|
59
|
+
return sorted(registered, key=lambda model: str(model._meta.verbose_name))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _filter_models_for_request(models, request):
|
|
63
|
+
"""Filter ``models`` to only those viewable by ``request.user``."""
|
|
64
|
+
|
|
65
|
+
allowed = []
|
|
66
|
+
for model in models:
|
|
67
|
+
model_admin = admin.site._registry.get(model)
|
|
68
|
+
if model_admin is None:
|
|
69
|
+
continue
|
|
70
|
+
if not model_admin.has_module_permission(request):
|
|
71
|
+
continue
|
|
72
|
+
if not model_admin.has_view_permission(request, obj=None):
|
|
73
|
+
continue
|
|
74
|
+
allowed.append(model)
|
|
75
|
+
return allowed
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _admin_has_app_permission(request, app_label: str) -> bool:
|
|
79
|
+
"""Return whether the admin user can access the given app."""
|
|
80
|
+
|
|
81
|
+
has_app_permission = getattr(admin.site, "has_app_permission", None)
|
|
82
|
+
if callable(has_app_permission):
|
|
83
|
+
return has_app_permission(request, app_label)
|
|
84
|
+
return bool(admin.site.get_app_list(request, app_label))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _resolve_related_model(field, default_app_label: str):
|
|
88
|
+
"""Resolve the Django model class referenced by ``field``."""
|
|
89
|
+
|
|
90
|
+
remote = getattr(getattr(field, "remote_field", None), "model", None)
|
|
91
|
+
if remote is None:
|
|
92
|
+
return None
|
|
93
|
+
if isinstance(remote, str):
|
|
94
|
+
if "." in remote:
|
|
95
|
+
app_label, model_name = remote.split(".", 1)
|
|
96
|
+
else:
|
|
97
|
+
app_label, model_name = default_app_label, remote
|
|
98
|
+
try:
|
|
99
|
+
remote = django_apps.get_model(app_label, model_name)
|
|
100
|
+
except LookupError:
|
|
101
|
+
return None
|
|
102
|
+
return remote
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _graph_field_type(field, default_app_label: str) -> str:
|
|
106
|
+
"""Format a field description for node labels."""
|
|
107
|
+
|
|
108
|
+
base = field.get_internal_type()
|
|
109
|
+
related = _resolve_related_model(field, default_app_label)
|
|
110
|
+
if related is not None:
|
|
111
|
+
base = f"{base} → {related._meta.object_name}"
|
|
112
|
+
return base
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _build_model_graph(models):
|
|
116
|
+
"""Generate a GraphViz ``Digraph`` for the provided ``models``."""
|
|
117
|
+
|
|
118
|
+
if Digraph is None:
|
|
119
|
+
raise RuntimeError("Graphviz is not installed")
|
|
120
|
+
|
|
121
|
+
graph = Digraph(
|
|
122
|
+
"admin_app_models",
|
|
123
|
+
graph_attr={
|
|
124
|
+
"rankdir": "LR",
|
|
125
|
+
"splines": "ortho",
|
|
126
|
+
"nodesep": "0.8",
|
|
127
|
+
"ranksep": "1.0",
|
|
128
|
+
},
|
|
129
|
+
node_attr={
|
|
130
|
+
"shape": "plaintext",
|
|
131
|
+
"fontname": "Helvetica",
|
|
132
|
+
},
|
|
133
|
+
edge_attr={"fontname": "Helvetica"},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
node_ids = {}
|
|
137
|
+
for model in models:
|
|
138
|
+
node_id = f"{model._meta.app_label}.{model._meta.model_name}"
|
|
139
|
+
node_ids[model] = node_id
|
|
140
|
+
|
|
141
|
+
rows = [
|
|
142
|
+
'<tr><td bgcolor="#1f2933" colspan="2"><font color="white"><b>'
|
|
143
|
+
f"{escape(model._meta.object_name)}"
|
|
144
|
+
"</b></font></td></tr>"
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
verbose_name = str(model._meta.verbose_name)
|
|
148
|
+
if verbose_name and verbose_name != model._meta.object_name:
|
|
149
|
+
rows.append(
|
|
150
|
+
'<tr><td colspan="2"><i>' f"{escape(verbose_name)}" "</i></td></tr>"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
for field in model._meta.concrete_fields:
|
|
154
|
+
if field.auto_created and not field.concrete:
|
|
155
|
+
continue
|
|
156
|
+
name = escape(field.name)
|
|
157
|
+
if field.primary_key:
|
|
158
|
+
name = f"<u>{name}</u>"
|
|
159
|
+
type_label = escape(_graph_field_type(field, model._meta.app_label))
|
|
160
|
+
rows.append(
|
|
161
|
+
'<tr><td align="left">'
|
|
162
|
+
f"{name}"
|
|
163
|
+
'</td><td align="left">'
|
|
164
|
+
f"{type_label}"
|
|
165
|
+
"</td></tr>"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
for field in model._meta.local_many_to_many:
|
|
169
|
+
name = escape(field.name)
|
|
170
|
+
type_label = _graph_field_type(field, model._meta.app_label)
|
|
171
|
+
rows.append(
|
|
172
|
+
'<tr><td align="left">'
|
|
173
|
+
f"{name}"
|
|
174
|
+
'</td><td align="left">'
|
|
175
|
+
f"{escape(type_label)}"
|
|
176
|
+
"</td></tr>"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
label = '<\n <table BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">\n '
|
|
180
|
+
label += "\n ".join(rows)
|
|
181
|
+
label += "\n </table>\n>"
|
|
182
|
+
graph.node(node_id, label=label)
|
|
183
|
+
|
|
184
|
+
edges = set()
|
|
185
|
+
for model in models:
|
|
186
|
+
source_id = node_ids[model]
|
|
187
|
+
for field in model._meta.concrete_fields:
|
|
188
|
+
related = _resolve_related_model(field, model._meta.app_label)
|
|
189
|
+
if related not in node_ids:
|
|
190
|
+
continue
|
|
191
|
+
attrs = {"label": field.name}
|
|
192
|
+
if getattr(field, "one_to_one", False):
|
|
193
|
+
attrs.update({"arrowhead": "onormal", "arrowtail": "none"})
|
|
194
|
+
key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
|
|
195
|
+
if key not in edges:
|
|
196
|
+
edges.add(key)
|
|
197
|
+
graph.edge(source_id, node_ids[related], **attrs)
|
|
198
|
+
|
|
199
|
+
for field in model._meta.local_many_to_many:
|
|
200
|
+
related = _resolve_related_model(field, model._meta.app_label)
|
|
201
|
+
if related not in node_ids:
|
|
202
|
+
continue
|
|
203
|
+
attrs = {
|
|
204
|
+
"label": f"{field.name} (M2M)",
|
|
205
|
+
"dir": "both",
|
|
206
|
+
"arrowhead": "normal",
|
|
207
|
+
"arrowtail": "normal",
|
|
208
|
+
}
|
|
209
|
+
key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
|
|
210
|
+
if key not in edges:
|
|
211
|
+
edges.add(key)
|
|
212
|
+
graph.edge(source_id, node_ids[related], **attrs)
|
|
213
|
+
|
|
214
|
+
return graph
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@staff_member_required
|
|
218
|
+
def admin_model_graph(request, app_label: str):
|
|
219
|
+
"""Render a GraphViz-powered diagram for the admin app grouping."""
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
app_config = django_apps.get_app_config(app_label)
|
|
223
|
+
except LookupError as exc: # pragma: no cover - invalid app label
|
|
224
|
+
raise Http404("Unknown application") from exc
|
|
225
|
+
|
|
226
|
+
models = _get_registered_models(app_label)
|
|
227
|
+
if not models:
|
|
228
|
+
raise Http404("No admin models registered for this application")
|
|
229
|
+
|
|
230
|
+
if not _admin_has_app_permission(request, app_label):
|
|
231
|
+
raise PermissionDenied
|
|
232
|
+
|
|
233
|
+
models = _filter_models_for_request(models, request)
|
|
234
|
+
if not models:
|
|
235
|
+
raise PermissionDenied
|
|
236
|
+
|
|
237
|
+
if Digraph is None: # pragma: no cover - dependency missing is unexpected
|
|
238
|
+
raise Http404("Graph visualization support is unavailable")
|
|
239
|
+
|
|
240
|
+
graph = _build_model_graph(models)
|
|
241
|
+
graph_source = graph.source
|
|
242
|
+
|
|
243
|
+
graph_svg = ""
|
|
244
|
+
graph_error = ""
|
|
245
|
+
graph_engine = getattr(graph, "engine", "dot")
|
|
246
|
+
engine_path = shutil.which(str(graph_engine))
|
|
247
|
+
download_format = request.GET.get("format")
|
|
248
|
+
|
|
249
|
+
if download_format == "pdf":
|
|
250
|
+
if engine_path is None:
|
|
251
|
+
messages.error(
|
|
252
|
+
request,
|
|
253
|
+
_(
|
|
254
|
+
"Graphviz executables are required to download the diagram as a PDF. Install Graphviz on the server and try again."
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
try:
|
|
259
|
+
pdf_output = graph.pipe(format="pdf")
|
|
260
|
+
except (ExecutableNotFound, CalledProcessError) as exc:
|
|
261
|
+
logger.warning(
|
|
262
|
+
"Graphviz PDF rendering failed for admin model graph (engine=%s)",
|
|
263
|
+
graph_engine,
|
|
264
|
+
exc_info=exc,
|
|
265
|
+
)
|
|
266
|
+
messages.error(
|
|
267
|
+
request,
|
|
268
|
+
_(
|
|
269
|
+
"An error occurred while generating the PDF diagram. Check the server logs for details."
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
else:
|
|
273
|
+
filename = slugify(app_config.verbose_name) or app_label
|
|
274
|
+
response = HttpResponse(pdf_output, content_type="application/pdf")
|
|
275
|
+
response["Content-Disposition"] = (
|
|
276
|
+
f'attachment; filename="{filename}-model-graph.pdf"'
|
|
277
|
+
)
|
|
278
|
+
return response
|
|
279
|
+
|
|
280
|
+
params = request.GET.copy()
|
|
281
|
+
if "format" in params:
|
|
282
|
+
del params["format"]
|
|
283
|
+
query_string = params.urlencode()
|
|
284
|
+
redirect_url = request.path
|
|
285
|
+
if query_string:
|
|
286
|
+
redirect_url = f"{request.path}?{query_string}"
|
|
287
|
+
return redirect(redirect_url)
|
|
288
|
+
|
|
289
|
+
if engine_path is None:
|
|
290
|
+
graph_error = _(
|
|
291
|
+
"Graphviz executables are required to render this diagram. Install Graphviz on the server and try again."
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
try:
|
|
295
|
+
svg_output = graph.pipe(format="svg", encoding="utf-8")
|
|
296
|
+
except (ExecutableNotFound, CalledProcessError) as exc:
|
|
297
|
+
logger.warning(
|
|
298
|
+
"Graphviz rendering failed for admin model graph (engine=%s)",
|
|
299
|
+
graph_engine,
|
|
300
|
+
exc_info=exc,
|
|
301
|
+
)
|
|
302
|
+
graph_error = _(
|
|
303
|
+
"An error occurred while rendering the diagram. Check the server logs for details."
|
|
304
|
+
)
|
|
305
|
+
else:
|
|
306
|
+
svg_start = svg_output.find("<svg")
|
|
307
|
+
if svg_start != -1:
|
|
308
|
+
svg_output = svg_output[svg_start:]
|
|
309
|
+
label = _("%(app)s model diagram") % {"app": app_config.verbose_name}
|
|
310
|
+
graph_svg = svg_output.replace(
|
|
311
|
+
"<svg", f'<svg role="img" aria-label="{escape(label)}"', 1
|
|
312
|
+
)
|
|
313
|
+
if not graph_svg:
|
|
314
|
+
graph_error = _("Graphviz did not return any diagram output.")
|
|
315
|
+
|
|
316
|
+
model_links = []
|
|
317
|
+
for model in models:
|
|
318
|
+
opts = model._meta
|
|
319
|
+
try:
|
|
320
|
+
url = reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
|
|
321
|
+
except NoReverseMatch:
|
|
322
|
+
url = ""
|
|
323
|
+
model_links.append(
|
|
324
|
+
{
|
|
325
|
+
"label": str(opts.verbose_name_plural),
|
|
326
|
+
"url": url,
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
download_params = request.GET.copy()
|
|
331
|
+
download_params["format"] = "pdf"
|
|
332
|
+
download_url = f"{request.path}?{download_params.urlencode()}"
|
|
333
|
+
|
|
334
|
+
context = admin.site.each_context(request)
|
|
335
|
+
context.update(
|
|
336
|
+
{
|
|
337
|
+
"app_label": app_label,
|
|
338
|
+
"app_verbose_name": app_config.verbose_name,
|
|
339
|
+
"graph_source": graph_source,
|
|
340
|
+
"graph_svg": graph_svg,
|
|
341
|
+
"graph_error": graph_error,
|
|
342
|
+
"models": model_links,
|
|
343
|
+
"title": _("%(app)s model graph") % {"app": app_config.verbose_name},
|
|
344
|
+
"download_url": download_url,
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return TemplateResponse(request, "admin/model_graph.html", context)
|
|
349
|
+
|
|
350
|
+
|
|
30
351
|
@landing("Home")
|
|
352
|
+
@never_cache
|
|
31
353
|
def index(request):
|
|
32
354
|
site = get_site(request)
|
|
33
355
|
if site:
|
|
@@ -45,18 +367,27 @@ def index(request):
|
|
|
45
367
|
.first()
|
|
46
368
|
)
|
|
47
369
|
app_slug = app.path.strip("/") if app else ""
|
|
48
|
-
readme_base =
|
|
49
|
-
|
|
50
|
-
|
|
370
|
+
readme_base = (
|
|
371
|
+
Path(settings.BASE_DIR) / app_slug if app_slug else Path(settings.BASE_DIR)
|
|
372
|
+
)
|
|
373
|
+
lang = getattr(request, "LANGUAGE_CODE", "")
|
|
374
|
+
lang = lang.replace("_", "-").lower()
|
|
375
|
+
root_base = Path(settings.BASE_DIR)
|
|
376
|
+
candidates = []
|
|
51
377
|
if lang:
|
|
52
|
-
|
|
53
|
-
|
|
378
|
+
candidates.append(readme_base / f"README.{lang}.md")
|
|
379
|
+
short = lang.split("-")[0]
|
|
380
|
+
if short != lang:
|
|
381
|
+
candidates.append(readme_base / f"README.{short}.md")
|
|
382
|
+
candidates.append(readme_base / "README.md")
|
|
383
|
+
if readme_base != root_base:
|
|
384
|
+
if lang:
|
|
385
|
+
candidates.append(root_base / f"README.{lang}.md")
|
|
54
386
|
short = lang.split("-")[0]
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if
|
|
59
|
-
readme_file = Path(settings.BASE_DIR) / "README.md"
|
|
387
|
+
if short != lang:
|
|
388
|
+
candidates.append(root_base / f"README.{short}.md")
|
|
389
|
+
candidates.append(root_base / "README.md")
|
|
390
|
+
readme_file = next((p for p in candidates if p.exists()), root_base / "README.md")
|
|
60
391
|
text = readme_file.read_text(encoding="utf-8")
|
|
61
392
|
md = markdown.Markdown(extensions=["toc", "tables"])
|
|
62
393
|
html = md.convert(text)
|
|
@@ -68,7 +399,9 @@ def index(request):
|
|
|
68
399
|
toc_html = toc_html.strip()
|
|
69
400
|
title = "README" if readme_file.name.startswith("README") else readme_file.stem
|
|
70
401
|
context = {"content": html, "title": title, "toc": toc_html}
|
|
71
|
-
|
|
402
|
+
response = render(request, "pages/readme.html", context)
|
|
403
|
+
patch_vary_headers(response, ["Accept-Language", "Cookie"])
|
|
404
|
+
return response
|
|
72
405
|
|
|
73
406
|
|
|
74
407
|
def sitemap(request):
|
|
@@ -91,6 +424,31 @@ def sitemap(request):
|
|
|
91
424
|
return HttpResponse("\n".join(lines), content_type="application/xml")
|
|
92
425
|
|
|
93
426
|
|
|
427
|
+
def release_checklist(request):
|
|
428
|
+
file_path = Path(settings.BASE_DIR) / "releases" / "release-checklist.md"
|
|
429
|
+
if not file_path.exists():
|
|
430
|
+
raise Http404("Release checklist not found")
|
|
431
|
+
text = file_path.read_text(encoding="utf-8")
|
|
432
|
+
md = markdown.Markdown(extensions=["toc", "tables"])
|
|
433
|
+
html = md.convert(text)
|
|
434
|
+
toc_html = md.toc
|
|
435
|
+
if toc_html.strip().startswith('<div class="toc">'):
|
|
436
|
+
toc_html = toc_html.strip()[len('<div class="toc">') :]
|
|
437
|
+
if toc_html.endswith("</div>"):
|
|
438
|
+
toc_html = toc_html[: -len("</div>")]
|
|
439
|
+
toc_html = toc_html.strip()
|
|
440
|
+
context = {"content": html, "title": "Release Checklist", "toc": toc_html}
|
|
441
|
+
response = render(request, "pages/readme.html", context)
|
|
442
|
+
patch_vary_headers(response, ["Accept-Language", "Cookie"])
|
|
443
|
+
return response
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@csrf_exempt
|
|
447
|
+
def datasette_auth(request):
|
|
448
|
+
if request.user.is_authenticated:
|
|
449
|
+
return HttpResponse("OK")
|
|
450
|
+
return HttpResponse(status=401)
|
|
451
|
+
|
|
94
452
|
|
|
95
453
|
class CustomLoginView(LoginView):
|
|
96
454
|
"""Login view that redirects staff to the admin."""
|
|
@@ -102,6 +460,19 @@ class CustomLoginView(LoginView):
|
|
|
102
460
|
return redirect(self.get_success_url())
|
|
103
461
|
return super().dispatch(request, *args, **kwargs)
|
|
104
462
|
|
|
463
|
+
def get_context_data(self, **kwargs):
|
|
464
|
+
context = super(LoginView, self).get_context_data(**kwargs)
|
|
465
|
+
current_site = get_site(self.request)
|
|
466
|
+
context.update(
|
|
467
|
+
{
|
|
468
|
+
"site": current_site,
|
|
469
|
+
"site_name": getattr(current_site, "name", ""),
|
|
470
|
+
"next": self.get_success_url(),
|
|
471
|
+
"can_request_invite": mailer.can_send_email(),
|
|
472
|
+
}
|
|
473
|
+
)
|
|
474
|
+
return context
|
|
475
|
+
|
|
105
476
|
def get_success_url(self):
|
|
106
477
|
redirect_url = self.get_redirect_url()
|
|
107
478
|
if redirect_url:
|
|
@@ -118,6 +489,7 @@ class InvitationRequestForm(forms.Form):
|
|
|
118
489
|
email = forms.EmailField()
|
|
119
490
|
comment = forms.CharField(required=False, widget=forms.Textarea, label=_("Comment"))
|
|
120
491
|
|
|
492
|
+
|
|
121
493
|
@csrf_exempt
|
|
122
494
|
@ensure_csrf_cookie
|
|
123
495
|
def request_invite(request):
|
|
@@ -126,14 +498,17 @@ def request_invite(request):
|
|
|
126
498
|
if request.method == "POST" and form.is_valid():
|
|
127
499
|
email = form.cleaned_data["email"]
|
|
128
500
|
comment = form.cleaned_data.get("comment", "")
|
|
129
|
-
|
|
501
|
+
ip_address = request.META.get("REMOTE_ADDR")
|
|
502
|
+
mac_address = public_wifi.resolve_mac_address(ip_address)
|
|
503
|
+
lead = InviteLead.objects.create(
|
|
130
504
|
email=email,
|
|
131
505
|
comment=comment,
|
|
132
506
|
user=request.user if request.user.is_authenticated else None,
|
|
133
507
|
path=request.path,
|
|
134
508
|
referer=request.META.get("HTTP_REFERER", ""),
|
|
135
509
|
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
|
136
|
-
ip_address=
|
|
510
|
+
ip_address=ip_address,
|
|
511
|
+
mac_address=mac_address or "",
|
|
137
512
|
)
|
|
138
513
|
logger.info("Invitation requested for %s", email)
|
|
139
514
|
User = get_user_model()
|
|
@@ -147,20 +522,44 @@ def request_invite(request):
|
|
|
147
522
|
reverse("pages:invitation-login", args=[uid, token])
|
|
148
523
|
)
|
|
149
524
|
subject = _("Your invitation link")
|
|
150
|
-
body = _(
|
|
151
|
-
"
|
|
152
|
-
|
|
525
|
+
body = _("Use the following link to access your account: %(link)s") % {
|
|
526
|
+
"link": link
|
|
527
|
+
}
|
|
153
528
|
try:
|
|
529
|
+
node_error = None
|
|
154
530
|
node = Node.get_local()
|
|
155
531
|
if node:
|
|
156
|
-
|
|
532
|
+
try:
|
|
533
|
+
result = node.send_mail(subject, body, [email])
|
|
534
|
+
except Exception as exc:
|
|
535
|
+
node_error = exc
|
|
536
|
+
logger.exception(
|
|
537
|
+
"Node send_mail failed, falling back to default backend"
|
|
538
|
+
)
|
|
539
|
+
result = mailer.send(
|
|
540
|
+
subject, body, [email], settings.DEFAULT_FROM_EMAIL
|
|
541
|
+
)
|
|
542
|
+
else:
|
|
543
|
+
result = mailer.send(
|
|
544
|
+
subject, body, [email], settings.DEFAULT_FROM_EMAIL
|
|
545
|
+
)
|
|
546
|
+
lead.sent_on = timezone.now()
|
|
547
|
+
if node_error:
|
|
548
|
+
lead.error = (
|
|
549
|
+
f"Node email send failed: {node_error}. "
|
|
550
|
+
"Invite was sent using default mail backend; ensure the "
|
|
551
|
+
"node's email service is running or check its configuration."
|
|
552
|
+
)
|
|
157
553
|
else:
|
|
158
|
-
|
|
554
|
+
lead.error = ""
|
|
159
555
|
logger.info(
|
|
160
556
|
"Invitation email sent to %s (user %s): %s", email, user.pk, result
|
|
161
557
|
)
|
|
162
|
-
except Exception:
|
|
558
|
+
except Exception as exc:
|
|
559
|
+
lead.error = f"{exc}. Ensure the email service is reachable and settings are correct."
|
|
163
560
|
logger.exception("Failed to send invitation email to %s", email)
|
|
561
|
+
if lead.sent_on or lead.error:
|
|
562
|
+
lead.save(update_fields=["sent_on", "error"])
|
|
164
563
|
sent = True
|
|
165
564
|
return render(request, "pages/request_invite.html", {"form": form, "sent": sent})
|
|
166
565
|
|
|
@@ -199,13 +598,165 @@ def invitation_login(request, uidb64, token):
|
|
|
199
598
|
user.set_password(password)
|
|
200
599
|
user.is_active = True
|
|
201
600
|
user.save()
|
|
601
|
+
node = Node.get_local()
|
|
602
|
+
if node and node.has_feature("ap-public-wifi"):
|
|
603
|
+
mac_address = public_wifi.resolve_mac_address(
|
|
604
|
+
request.META.get("REMOTE_ADDR")
|
|
605
|
+
)
|
|
606
|
+
if not mac_address:
|
|
607
|
+
mac_address = (
|
|
608
|
+
InviteLead.objects.filter(email__iexact=user.email)
|
|
609
|
+
.exclude(mac_address="")
|
|
610
|
+
.order_by("-created_on")
|
|
611
|
+
.values_list("mac_address", flat=True)
|
|
612
|
+
.first()
|
|
613
|
+
)
|
|
614
|
+
if mac_address:
|
|
615
|
+
public_wifi.grant_public_access(user, mac_address)
|
|
202
616
|
login(request, user, backend="core.backends.LocalhostAdminBackend")
|
|
203
617
|
return redirect(reverse("admin:index") if user.is_staff else "/")
|
|
204
618
|
return render(request, "pages/invitation_login.html", {"form": form})
|
|
205
619
|
|
|
206
620
|
|
|
621
|
+
class ClientReportForm(forms.Form):
|
|
622
|
+
PERIOD_CHOICES = [
|
|
623
|
+
("range", _("Date range")),
|
|
624
|
+
("week", _("Week")),
|
|
625
|
+
("month", _("Month")),
|
|
626
|
+
]
|
|
627
|
+
RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
|
|
628
|
+
period = forms.ChoiceField(
|
|
629
|
+
choices=PERIOD_CHOICES, widget=forms.RadioSelect, initial="range"
|
|
630
|
+
)
|
|
631
|
+
start = forms.DateField(
|
|
632
|
+
label=_("Start date"),
|
|
633
|
+
required=False,
|
|
634
|
+
widget=forms.DateInput(attrs={"type": "date"}),
|
|
635
|
+
)
|
|
636
|
+
end = forms.DateField(
|
|
637
|
+
label=_("End date"),
|
|
638
|
+
required=False,
|
|
639
|
+
widget=forms.DateInput(attrs={"type": "date"}),
|
|
640
|
+
)
|
|
641
|
+
week = forms.CharField(
|
|
642
|
+
label=_("Week"),
|
|
643
|
+
required=False,
|
|
644
|
+
widget=forms.TextInput(attrs={"type": "week"}),
|
|
645
|
+
)
|
|
646
|
+
month = forms.DateField(
|
|
647
|
+
label=_("Month"),
|
|
648
|
+
required=False,
|
|
649
|
+
widget=forms.DateInput(attrs={"type": "month"}),
|
|
650
|
+
)
|
|
651
|
+
owner = forms.ModelChoiceField(
|
|
652
|
+
queryset=get_user_model().objects.all(), required=False
|
|
653
|
+
)
|
|
654
|
+
destinations = forms.CharField(
|
|
655
|
+
label=_("Email destinations"),
|
|
656
|
+
required=False,
|
|
657
|
+
widget=forms.Textarea(attrs={"rows": 2}),
|
|
658
|
+
help_text=_("Separate addresses with commas or new lines."),
|
|
659
|
+
)
|
|
660
|
+
recurrence = forms.ChoiceField(
|
|
661
|
+
label=_("Recurrency"),
|
|
662
|
+
choices=RECURRENCE_CHOICES,
|
|
663
|
+
initial=ClientReportSchedule.PERIODICITY_NONE,
|
|
664
|
+
)
|
|
665
|
+
disable_emails = forms.BooleanField(
|
|
666
|
+
label=_("Disable email delivery"),
|
|
667
|
+
required=False,
|
|
668
|
+
help_text=_("Generate files without sending emails."),
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
def __init__(self, *args, request=None, **kwargs):
|
|
672
|
+
self.request = request
|
|
673
|
+
super().__init__(*args, **kwargs)
|
|
674
|
+
if request and getattr(request, "user", None) and request.user.is_authenticated:
|
|
675
|
+
self.fields["owner"].initial = request.user.pk
|
|
676
|
+
|
|
677
|
+
def clean(self):
|
|
678
|
+
cleaned = super().clean()
|
|
679
|
+
period = cleaned.get("period")
|
|
680
|
+
if period == "range":
|
|
681
|
+
if not cleaned.get("start") or not cleaned.get("end"):
|
|
682
|
+
raise forms.ValidationError(_("Please provide start and end dates."))
|
|
683
|
+
elif period == "week":
|
|
684
|
+
week_str = cleaned.get("week")
|
|
685
|
+
if not week_str:
|
|
686
|
+
raise forms.ValidationError(_("Please select a week."))
|
|
687
|
+
year, week_num = week_str.split("-W")
|
|
688
|
+
start = datetime.date.fromisocalendar(int(year), int(week_num), 1)
|
|
689
|
+
cleaned["start"] = start
|
|
690
|
+
cleaned["end"] = start + datetime.timedelta(days=6)
|
|
691
|
+
elif period == "month":
|
|
692
|
+
month_dt = cleaned.get("month")
|
|
693
|
+
if not month_dt:
|
|
694
|
+
raise forms.ValidationError(_("Please select a month."))
|
|
695
|
+
start = month_dt.replace(day=1)
|
|
696
|
+
last_day = calendar.monthrange(month_dt.year, month_dt.month)[1]
|
|
697
|
+
cleaned["start"] = start
|
|
698
|
+
cleaned["end"] = month_dt.replace(day=last_day)
|
|
699
|
+
return cleaned
|
|
700
|
+
|
|
701
|
+
def clean_destinations(self):
|
|
702
|
+
raw = self.cleaned_data.get("destinations", "")
|
|
703
|
+
if not raw:
|
|
704
|
+
return []
|
|
705
|
+
validator = EmailValidator()
|
|
706
|
+
seen: set[str] = set()
|
|
707
|
+
emails: list[str] = []
|
|
708
|
+
for part in re.split(r"[\s,]+", raw):
|
|
709
|
+
candidate = part.strip()
|
|
710
|
+
if not candidate:
|
|
711
|
+
continue
|
|
712
|
+
validator(candidate)
|
|
713
|
+
key = candidate.lower()
|
|
714
|
+
if key in seen:
|
|
715
|
+
continue
|
|
716
|
+
seen.add(key)
|
|
717
|
+
emails.append(candidate)
|
|
718
|
+
return emails
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
@live_update()
|
|
722
|
+
def client_report(request):
|
|
723
|
+
form = ClientReportForm(request.POST or None, request=request)
|
|
724
|
+
report = None
|
|
725
|
+
schedule = None
|
|
726
|
+
if request.method == "POST" and form.is_valid():
|
|
727
|
+
owner = form.cleaned_data.get("owner")
|
|
728
|
+
if not owner and request.user.is_authenticated:
|
|
729
|
+
owner = request.user
|
|
730
|
+
report = ClientReport.generate(
|
|
731
|
+
form.cleaned_data["start"],
|
|
732
|
+
form.cleaned_data["end"],
|
|
733
|
+
owner=owner,
|
|
734
|
+
recipients=form.cleaned_data.get("destinations"),
|
|
735
|
+
disable_emails=form.cleaned_data.get("disable_emails", False),
|
|
736
|
+
)
|
|
737
|
+
report.store_local_copy()
|
|
738
|
+
recurrence = form.cleaned_data.get("recurrence")
|
|
739
|
+
if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
|
|
740
|
+
schedule = ClientReportSchedule.objects.create(
|
|
741
|
+
owner=owner,
|
|
742
|
+
created_by=request.user if request.user.is_authenticated else None,
|
|
743
|
+
periodicity=recurrence,
|
|
744
|
+
email_recipients=form.cleaned_data.get("destinations", []),
|
|
745
|
+
disable_emails=form.cleaned_data.get("disable_emails", False),
|
|
746
|
+
)
|
|
747
|
+
report.schedule = schedule
|
|
748
|
+
report.save(update_fields=["schedule"])
|
|
749
|
+
messages.success(
|
|
750
|
+
request,
|
|
751
|
+
_(
|
|
752
|
+
"Client report schedule created; future reports will be generated automatically."
|
|
753
|
+
),
|
|
754
|
+
)
|
|
755
|
+
context = {"form": form, "report": report, "schedule": schedule}
|
|
756
|
+
return render(request, "pages/client_report.html", context)
|
|
757
|
+
|
|
758
|
+
|
|
207
759
|
def csrf_failure(request, reason=""):
|
|
208
760
|
"""Custom CSRF failure view with a friendly message."""
|
|
209
761
|
logger.warning("CSRF failure on %s: %s", request.path, reason)
|
|
210
762
|
return render(request, "pages/csrf_failure.html", status=403)
|
|
211
|
-
|