arthexis 0.1.9__py3-none-any.whl → 0.1.26__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.26.dist-info/METADATA +272 -0
- arthexis-0.1.26.dist-info/RECORD +111 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +29 -29
- config/auth_app.py +7 -7
- config/celery.py +32 -25
- config/context_processors.py +67 -68
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +71 -25
- config/offline.py +49 -49
- config/settings.py +676 -492
- config/settings_helpers.py +109 -0
- config/urls.py +228 -159
- config/wsgi.py +17 -17
- core/admin.py +4052 -2066
- core/admin_history.py +50 -50
- core/admindocs.py +192 -151
- core/apps.py +350 -223
- core/auto_upgrade.py +72 -0
- core/backends.py +311 -124
- core/changelog.py +403 -0
- core/entity.py +149 -133
- core/environment.py +60 -43
- core/fields.py +168 -75
- core/form_fields.py +75 -0
- core/github_helper.py +188 -25
- core/github_issues.py +183 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +89 -83
- core/middleware.py +91 -91
- core/models.py +5041 -2195
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +107 -0
- core/release.py +940 -346
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -131
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +250 -284
- core/system.py +1425 -230
- core/tasks.py +538 -199
- core/temp_passwords.py +181 -0
- core/test_system_info.py +202 -43
- core/tests.py +2673 -1069
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +681 -495
- core/views.py +2484 -789
- core/widgets.py +213 -51
- nodes/admin.py +2236 -445
- nodes/apps.py +98 -70
- nodes/backends.py +160 -53
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/lcd.py +165 -165
- nodes/models.py +2375 -870
- nodes/reports.py +411 -0
- nodes/rfid_sync.py +210 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +141 -46
- nodes/tests.py +5045 -1489
- nodes/urls.py +29 -13
- nodes/utils.py +172 -73
- nodes/views.py +1768 -304
- ocpp/admin.py +1775 -481
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1843 -630
- ocpp/evcs.py +844 -928
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +1417 -640
- ocpp/network.py +398 -0
- ocpp/reference_utils.py +42 -0
- ocpp/routing.py +11 -9
- ocpp/simulator.py +745 -368
- ocpp/status_display.py +26 -0
- ocpp/store.py +603 -403
- ocpp/tasks.py +479 -31
- ocpp/test_export_import.py +131 -130
- ocpp/test_rfid.py +1072 -540
- ocpp/tests.py +5494 -2296
- ocpp/transactions_io.py +197 -165
- ocpp/urls.py +50 -50
- ocpp/views.py +2024 -912
- pages/admin.py +1123 -396
- pages/apps.py +45 -10
- pages/checks.py +40 -40
- pages/context_processors.py +151 -85
- pages/defaults.py +13 -0
- pages/forms.py +221 -0
- pages/middleware.py +213 -153
- pages/models.py +720 -252
- pages/module_defaults.py +156 -0
- pages/site_config.py +137 -0
- pages/tasks.py +74 -0
- pages/tests.py +4009 -1389
- pages/urls.py +38 -20
- pages/utils.py +93 -12
- pages/views.py +1736 -762
- arthexis-0.1.9.dist-info/METADATA +0 -168
- arthexis-0.1.9.dist-info/RECORD +0 -92
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- nodes/actions.py +0 -70
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
pages/views.py
CHANGED
|
@@ -1,762 +1,1736 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
|
|
14
|
-
from django.
|
|
15
|
-
from django.contrib
|
|
16
|
-
from django import
|
|
17
|
-
from django.
|
|
18
|
-
from
|
|
19
|
-
from django.
|
|
20
|
-
from django.
|
|
21
|
-
from
|
|
22
|
-
from django
|
|
23
|
-
from django.
|
|
24
|
-
from
|
|
25
|
-
from
|
|
26
|
-
from django.
|
|
27
|
-
from
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
from
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
import
|
|
45
|
-
from
|
|
46
|
-
from core.
|
|
47
|
-
from .
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def
|
|
219
|
-
"""
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
.
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
"
|
|
469
|
-
"
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
)
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
)
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1
|
+
import base64
|
|
2
|
+
import logging
|
|
3
|
+
import mimetypes
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
import datetime
|
|
7
|
+
import calendar
|
|
8
|
+
import io
|
|
9
|
+
import shutil
|
|
10
|
+
import re
|
|
11
|
+
from html import escape
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
from django.conf import settings
|
|
15
|
+
from django.contrib import admin
|
|
16
|
+
from django.contrib import messages
|
|
17
|
+
from django.contrib.admin.views.decorators import staff_member_required
|
|
18
|
+
from django.contrib.auth import get_user_model, login
|
|
19
|
+
from django.contrib.auth.decorators import login_required
|
|
20
|
+
from django.contrib.auth.tokens import default_token_generator
|
|
21
|
+
from django.contrib.auth.views import LoginView
|
|
22
|
+
from django import forms
|
|
23
|
+
from django.apps import apps as django_apps
|
|
24
|
+
from utils.decorators import security_group_required
|
|
25
|
+
from utils.sites import get_site
|
|
26
|
+
from django.contrib.staticfiles import finders
|
|
27
|
+
from django.http import (
|
|
28
|
+
FileResponse,
|
|
29
|
+
Http404,
|
|
30
|
+
HttpResponse,
|
|
31
|
+
HttpResponseForbidden,
|
|
32
|
+
HttpResponseRedirect,
|
|
33
|
+
JsonResponse,
|
|
34
|
+
)
|
|
35
|
+
from django.shortcuts import get_object_or_404, redirect, render
|
|
36
|
+
from nodes.models import Node
|
|
37
|
+
from nodes.utils import capture_screenshot, save_screenshot
|
|
38
|
+
from django.template import loader
|
|
39
|
+
from django.template.response import TemplateResponse
|
|
40
|
+
from django.test import RequestFactory, signals as test_signals
|
|
41
|
+
from django.urls import NoReverseMatch, reverse
|
|
42
|
+
from django.utils import timezone
|
|
43
|
+
from django.utils.encoding import force_bytes, force_str
|
|
44
|
+
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
|
45
|
+
from core import mailer, public_wifi
|
|
46
|
+
from core.backends import TOTP_DEVICE_NAME
|
|
47
|
+
from django.utils.translation import get_language, gettext as _
|
|
48
|
+
|
|
49
|
+
try: # pragma: no cover - compatibility shim for Django versions without constant
|
|
50
|
+
from django.utils.translation import LANGUAGE_SESSION_KEY
|
|
51
|
+
except ImportError: # pragma: no cover - fallback when constant is unavailable
|
|
52
|
+
LANGUAGE_SESSION_KEY = "_language"
|
|
53
|
+
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
|
54
|
+
from django.views.decorators.http import require_POST
|
|
55
|
+
from django.core.cache import cache
|
|
56
|
+
from django.views.decorators.cache import never_cache
|
|
57
|
+
from django.utils.cache import patch_cache_control, patch_vary_headers
|
|
58
|
+
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
|
|
59
|
+
from django.utils.text import slugify, Truncator
|
|
60
|
+
from django.core.validators import EmailValidator
|
|
61
|
+
from django.db.models import Q
|
|
62
|
+
from core.models import (
|
|
63
|
+
InviteLead,
|
|
64
|
+
ClientReport,
|
|
65
|
+
ClientReportSchedule,
|
|
66
|
+
SecurityGroup,
|
|
67
|
+
Todo,
|
|
68
|
+
)
|
|
69
|
+
from ocpp.models import Charger
|
|
70
|
+
from .utils import get_original_referer
|
|
71
|
+
|
|
72
|
+
try: # pragma: no cover - optional dependency guard
|
|
73
|
+
from graphviz import Digraph
|
|
74
|
+
from graphviz.backend import CalledProcessError, ExecutableNotFound
|
|
75
|
+
except ImportError: # pragma: no cover - handled gracefully in views
|
|
76
|
+
Digraph = None
|
|
77
|
+
CalledProcessError = ExecutableNotFound = None
|
|
78
|
+
|
|
79
|
+
import markdown
|
|
80
|
+
from django.utils._os import safe_join
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
MARKDOWN_EXTENSIONS = ["toc", "tables", "mdx_truly_sane_lists"]
|
|
84
|
+
|
|
85
|
+
MARKDOWN_IMAGE_PATTERN = re.compile(
|
|
86
|
+
r"(?P<prefix><img\b[^>]*\bsrc=[\"\'])(?P<scheme>(?:static|work))://(?P<path>[^\"\']+)(?P<suffix>[\"\'])",
|
|
87
|
+
re.IGNORECASE,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
ALLOWED_IMAGE_EXTENSIONS = {
|
|
91
|
+
".apng",
|
|
92
|
+
".avif",
|
|
93
|
+
".gif",
|
|
94
|
+
".jpg",
|
|
95
|
+
".jpeg",
|
|
96
|
+
".png",
|
|
97
|
+
".svg",
|
|
98
|
+
".webp",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _render_markdown_with_toc(text: str) -> tuple[str, str]:
|
|
103
|
+
"""Render ``text`` to HTML and return the HTML and stripped TOC."""
|
|
104
|
+
|
|
105
|
+
md = markdown.Markdown(extensions=MARKDOWN_EXTENSIONS)
|
|
106
|
+
html = md.convert(text)
|
|
107
|
+
html = _rewrite_markdown_asset_links(html)
|
|
108
|
+
toc_html = md.toc
|
|
109
|
+
toc_html = _strip_toc_wrapper(toc_html)
|
|
110
|
+
return html, toc_html
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _strip_toc_wrapper(toc_html: str) -> str:
|
|
114
|
+
"""Normalize ``markdown``'s TOC output by removing the wrapper ``div``."""
|
|
115
|
+
|
|
116
|
+
toc_html = toc_html.strip()
|
|
117
|
+
if toc_html.startswith('<div class="toc">'):
|
|
118
|
+
toc_html = toc_html[len('<div class="toc">') :]
|
|
119
|
+
if toc_html.endswith("</div>"):
|
|
120
|
+
toc_html = toc_html[: -len("</div>")]
|
|
121
|
+
return toc_html.strip()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _rewrite_markdown_asset_links(html: str) -> str:
|
|
125
|
+
"""Rewrite asset links that reference local asset schemes."""
|
|
126
|
+
|
|
127
|
+
def _replace(match: re.Match[str]) -> str:
|
|
128
|
+
scheme = match.group("scheme").lower()
|
|
129
|
+
asset_path = match.group("path").lstrip("/")
|
|
130
|
+
if not asset_path:
|
|
131
|
+
return match.group(0)
|
|
132
|
+
extension = Path(asset_path).suffix.lower()
|
|
133
|
+
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
134
|
+
return match.group(0)
|
|
135
|
+
try:
|
|
136
|
+
asset_url = reverse(
|
|
137
|
+
"pages:readme-asset",
|
|
138
|
+
kwargs={"source": scheme, "asset": asset_path},
|
|
139
|
+
)
|
|
140
|
+
except NoReverseMatch:
|
|
141
|
+
return match.group(0)
|
|
142
|
+
return f"{match.group('prefix')}{escape(asset_url)}{match.group('suffix')}"
|
|
143
|
+
|
|
144
|
+
return MARKDOWN_IMAGE_PATTERN.sub(_replace, html)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _resolve_static_asset(path: str) -> Path:
|
|
148
|
+
normalized = path.lstrip("/")
|
|
149
|
+
if not normalized:
|
|
150
|
+
raise Http404("Asset not found")
|
|
151
|
+
resolved = finders.find(normalized)
|
|
152
|
+
if not resolved:
|
|
153
|
+
raise Http404("Asset not found")
|
|
154
|
+
if isinstance(resolved, (list, tuple)):
|
|
155
|
+
resolved = resolved[0]
|
|
156
|
+
file_path = Path(resolved)
|
|
157
|
+
if file_path.is_dir():
|
|
158
|
+
raise Http404("Asset not found")
|
|
159
|
+
return file_path
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _resolve_work_asset(user, path: str) -> Path:
|
|
163
|
+
if not (user and getattr(user, "is_authenticated", False)):
|
|
164
|
+
raise PermissionDenied
|
|
165
|
+
normalized = path.lstrip("/")
|
|
166
|
+
if not normalized:
|
|
167
|
+
raise Http404("Asset not found")
|
|
168
|
+
username = getattr(user, "get_username", None)
|
|
169
|
+
if callable(username):
|
|
170
|
+
username = username()
|
|
171
|
+
else:
|
|
172
|
+
username = getattr(user, "username", "")
|
|
173
|
+
username_component = Path(str(username or user.pk)).name
|
|
174
|
+
base_work = Path(settings.BASE_DIR) / "work"
|
|
175
|
+
try:
|
|
176
|
+
user_dir = Path(safe_join(str(base_work), username_component))
|
|
177
|
+
asset_path = Path(safe_join(str(user_dir), normalized))
|
|
178
|
+
except SuspiciousFileOperation as exc:
|
|
179
|
+
logger.warning("Rejected suspicious work asset path: %s", normalized, exc_info=exc)
|
|
180
|
+
raise Http404("Asset not found") from exc
|
|
181
|
+
try:
|
|
182
|
+
user_dir_resolved = user_dir.resolve(strict=True)
|
|
183
|
+
except FileNotFoundError as exc:
|
|
184
|
+
logger.warning(
|
|
185
|
+
"Work directory missing for asset request: %s", user_dir, exc_info=exc
|
|
186
|
+
)
|
|
187
|
+
raise Http404("Asset not found") from exc
|
|
188
|
+
try:
|
|
189
|
+
asset_resolved = asset_path.resolve(strict=True)
|
|
190
|
+
except FileNotFoundError as exc:
|
|
191
|
+
raise Http404("Asset not found") from exc
|
|
192
|
+
try:
|
|
193
|
+
asset_resolved.relative_to(user_dir_resolved)
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
logger.warning(
|
|
196
|
+
"Rejected work asset outside directory: %s", asset_resolved, exc_info=exc
|
|
197
|
+
)
|
|
198
|
+
raise Http404("Asset not found") from exc
|
|
199
|
+
if asset_resolved.is_dir():
|
|
200
|
+
raise Http404("Asset not found")
|
|
201
|
+
return asset_resolved
|
|
202
|
+
from pages.utils import landing
|
|
203
|
+
from core.liveupdate import live_update
|
|
204
|
+
from django_otp import login as otp_login
|
|
205
|
+
from django_otp.plugins.otp_totp.models import TOTPDevice
|
|
206
|
+
import qrcode
|
|
207
|
+
from .forms import (
|
|
208
|
+
AuthenticatorEnrollmentForm,
|
|
209
|
+
AuthenticatorLoginForm,
|
|
210
|
+
UserStoryForm,
|
|
211
|
+
)
|
|
212
|
+
from .models import Module, RoleLanding, UserManual, UserStory
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
logger = logging.getLogger(__name__)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _get_registered_models(app_label: str):
|
|
219
|
+
"""Return admin-registered models for the given app label."""
|
|
220
|
+
|
|
221
|
+
registered = [
|
|
222
|
+
model for model in admin.site._registry if model._meta.app_label == app_label
|
|
223
|
+
]
|
|
224
|
+
return sorted(registered, key=lambda model: str(model._meta.verbose_name))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _get_client_ip(request) -> str:
|
|
228
|
+
"""Return the client IP from the request headers."""
|
|
229
|
+
|
|
230
|
+
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
|
231
|
+
if forwarded_for:
|
|
232
|
+
for value in forwarded_for.split(","):
|
|
233
|
+
candidate = value.strip()
|
|
234
|
+
if candidate:
|
|
235
|
+
return candidate
|
|
236
|
+
return request.META.get("REMOTE_ADDR", "")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _filter_models_for_request(models, request):
|
|
240
|
+
"""Filter ``models`` to only those viewable by ``request.user``."""
|
|
241
|
+
|
|
242
|
+
allowed = []
|
|
243
|
+
for model in models:
|
|
244
|
+
model_admin = admin.site._registry.get(model)
|
|
245
|
+
if model_admin is None:
|
|
246
|
+
continue
|
|
247
|
+
if not model_admin.has_module_permission(request) and not getattr(
|
|
248
|
+
request.user, "is_staff", False
|
|
249
|
+
):
|
|
250
|
+
continue
|
|
251
|
+
if not model_admin.has_view_permission(request, obj=None) and not getattr(
|
|
252
|
+
request.user, "is_staff", False
|
|
253
|
+
):
|
|
254
|
+
continue
|
|
255
|
+
allowed.append(model)
|
|
256
|
+
return allowed
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _admin_has_app_permission(request, app_label: str) -> bool:
|
|
260
|
+
"""Return whether the admin user can access the given app."""
|
|
261
|
+
|
|
262
|
+
has_app_permission = getattr(admin.site, "has_app_permission", None)
|
|
263
|
+
if callable(has_app_permission):
|
|
264
|
+
allowed = has_app_permission(request, app_label)
|
|
265
|
+
else:
|
|
266
|
+
allowed = bool(admin.site.get_app_list(request, app_label))
|
|
267
|
+
|
|
268
|
+
if not allowed and getattr(request.user, "is_staff", False):
|
|
269
|
+
return True
|
|
270
|
+
return allowed
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _resolve_related_model(field, default_app_label: str):
|
|
274
|
+
"""Resolve the Django model class referenced by ``field``."""
|
|
275
|
+
|
|
276
|
+
remote = getattr(getattr(field, "remote_field", None), "model", None)
|
|
277
|
+
if remote is None:
|
|
278
|
+
return None
|
|
279
|
+
if isinstance(remote, str):
|
|
280
|
+
if "." in remote:
|
|
281
|
+
app_label, model_name = remote.split(".", 1)
|
|
282
|
+
else:
|
|
283
|
+
app_label, model_name = default_app_label, remote
|
|
284
|
+
try:
|
|
285
|
+
remote = django_apps.get_model(app_label, model_name)
|
|
286
|
+
except LookupError:
|
|
287
|
+
return None
|
|
288
|
+
return remote
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _graph_field_type(field, default_app_label: str) -> str:
|
|
292
|
+
"""Format a field description for node labels."""
|
|
293
|
+
|
|
294
|
+
base = field.get_internal_type()
|
|
295
|
+
related = _resolve_related_model(field, default_app_label)
|
|
296
|
+
if related is not None:
|
|
297
|
+
base = f"{base} → {related._meta.object_name}"
|
|
298
|
+
return base
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _build_model_graph(models):
|
|
302
|
+
"""Generate a GraphViz ``Digraph`` for the provided ``models``."""
|
|
303
|
+
|
|
304
|
+
if Digraph is None:
|
|
305
|
+
raise RuntimeError("Graphviz is not installed")
|
|
306
|
+
|
|
307
|
+
graph = Digraph(
|
|
308
|
+
name="admin_app_models",
|
|
309
|
+
graph_attr={
|
|
310
|
+
"rankdir": "LR",
|
|
311
|
+
"splines": "ortho",
|
|
312
|
+
"nodesep": "0.8",
|
|
313
|
+
"ranksep": "1.0",
|
|
314
|
+
},
|
|
315
|
+
node_attr={
|
|
316
|
+
"shape": "plaintext",
|
|
317
|
+
"fontname": "Helvetica",
|
|
318
|
+
},
|
|
319
|
+
edge_attr={"fontname": "Helvetica"},
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
node_ids = {}
|
|
323
|
+
for model in models:
|
|
324
|
+
node_id = f"{model._meta.app_label}.{model._meta.model_name}"
|
|
325
|
+
node_ids[model] = node_id
|
|
326
|
+
|
|
327
|
+
rows = [
|
|
328
|
+
'<tr><td bgcolor="#1f2933" colspan="2"><font color="white"><b>'
|
|
329
|
+
f"{escape(model._meta.object_name)}"
|
|
330
|
+
"</b></font></td></tr>"
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
verbose_name = str(model._meta.verbose_name)
|
|
334
|
+
if verbose_name and verbose_name != model._meta.object_name:
|
|
335
|
+
rows.append(
|
|
336
|
+
'<tr><td colspan="2"><i>' f"{escape(verbose_name)}" "</i></td></tr>"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
for field in model._meta.concrete_fields:
|
|
340
|
+
if field.auto_created and not field.concrete:
|
|
341
|
+
continue
|
|
342
|
+
name = escape(field.name)
|
|
343
|
+
if field.primary_key:
|
|
344
|
+
name = f"<u>{name}</u>"
|
|
345
|
+
type_label = escape(_graph_field_type(field, model._meta.app_label))
|
|
346
|
+
rows.append(
|
|
347
|
+
'<tr><td align="left">'
|
|
348
|
+
f"{name}"
|
|
349
|
+
'</td><td align="left">'
|
|
350
|
+
f"{type_label}"
|
|
351
|
+
"</td></tr>"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
for field in model._meta.local_many_to_many:
|
|
355
|
+
name = escape(field.name)
|
|
356
|
+
type_label = _graph_field_type(field, model._meta.app_label)
|
|
357
|
+
rows.append(
|
|
358
|
+
'<tr><td align="left">'
|
|
359
|
+
f"{name}"
|
|
360
|
+
'</td><td align="left">'
|
|
361
|
+
f"{escape(type_label)}"
|
|
362
|
+
"</td></tr>"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
label = '<\n <table BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">\n '
|
|
366
|
+
label += "\n ".join(rows)
|
|
367
|
+
label += "\n </table>\n>"
|
|
368
|
+
graph.node(name=node_id, label=label)
|
|
369
|
+
|
|
370
|
+
edges = set()
|
|
371
|
+
for model in models:
|
|
372
|
+
source_id = node_ids[model]
|
|
373
|
+
for field in model._meta.concrete_fields:
|
|
374
|
+
related = _resolve_related_model(field, model._meta.app_label)
|
|
375
|
+
if related not in node_ids:
|
|
376
|
+
continue
|
|
377
|
+
attrs = {"label": field.name}
|
|
378
|
+
if getattr(field, "one_to_one", False):
|
|
379
|
+
attrs.update({"arrowhead": "onormal", "arrowtail": "none"})
|
|
380
|
+
key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
|
|
381
|
+
if key not in edges:
|
|
382
|
+
edges.add(key)
|
|
383
|
+
graph.edge(
|
|
384
|
+
tail_name=source_id,
|
|
385
|
+
head_name=node_ids[related],
|
|
386
|
+
**attrs,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
for field in model._meta.local_many_to_many:
|
|
390
|
+
related = _resolve_related_model(field, model._meta.app_label)
|
|
391
|
+
if related not in node_ids:
|
|
392
|
+
continue
|
|
393
|
+
attrs = {
|
|
394
|
+
"label": f"{field.name} (M2M)",
|
|
395
|
+
"dir": "both",
|
|
396
|
+
"arrowhead": "normal",
|
|
397
|
+
"arrowtail": "normal",
|
|
398
|
+
}
|
|
399
|
+
key = (source_id, node_ids[related], tuple(sorted(attrs.items())))
|
|
400
|
+
if key not in edges:
|
|
401
|
+
edges.add(key)
|
|
402
|
+
graph.edge(
|
|
403
|
+
tail_name=source_id,
|
|
404
|
+
head_name=node_ids[related],
|
|
405
|
+
**attrs,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
return graph
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@staff_member_required
|
|
412
|
+
def admin_model_graph(request, app_label: str):
|
|
413
|
+
"""Render a GraphViz-powered diagram for the admin app grouping."""
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
app_config = django_apps.get_app_config(app_label)
|
|
417
|
+
except LookupError as exc: # pragma: no cover - invalid app label
|
|
418
|
+
raise Http404("Unknown application") from exc
|
|
419
|
+
|
|
420
|
+
models = _get_registered_models(app_label)
|
|
421
|
+
if not models:
|
|
422
|
+
raise Http404("No admin models registered for this application")
|
|
423
|
+
|
|
424
|
+
if not _admin_has_app_permission(request, app_label):
|
|
425
|
+
raise PermissionDenied
|
|
426
|
+
|
|
427
|
+
models = _filter_models_for_request(models, request)
|
|
428
|
+
if not models:
|
|
429
|
+
raise PermissionDenied
|
|
430
|
+
|
|
431
|
+
if Digraph is None: # pragma: no cover - dependency missing is unexpected
|
|
432
|
+
raise Http404("Graph visualization support is unavailable")
|
|
433
|
+
|
|
434
|
+
graph = _build_model_graph(models)
|
|
435
|
+
graph_source = graph.source
|
|
436
|
+
|
|
437
|
+
graph_svg = ""
|
|
438
|
+
graph_error = ""
|
|
439
|
+
graph_engine = getattr(graph, "engine", "dot")
|
|
440
|
+
engine_path = shutil.which(str(graph_engine))
|
|
441
|
+
download_format = request.GET.get("format")
|
|
442
|
+
|
|
443
|
+
if download_format == "pdf":
|
|
444
|
+
if engine_path is None:
|
|
445
|
+
messages.error(
|
|
446
|
+
request,
|
|
447
|
+
_(
|
|
448
|
+
"Graphviz executables are required to download the diagram as a PDF. Install Graphviz on the server and try again."
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
else:
|
|
452
|
+
try:
|
|
453
|
+
pdf_output = graph.pipe(format="pdf")
|
|
454
|
+
except (ExecutableNotFound, CalledProcessError) as exc:
|
|
455
|
+
logger.warning(
|
|
456
|
+
"Graphviz PDF rendering failed for admin model graph (engine=%s)",
|
|
457
|
+
graph_engine,
|
|
458
|
+
exc_info=exc,
|
|
459
|
+
)
|
|
460
|
+
messages.error(
|
|
461
|
+
request,
|
|
462
|
+
_(
|
|
463
|
+
"An error occurred while generating the PDF diagram. Check the server logs for details."
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
else:
|
|
467
|
+
filename = slugify(app_config.verbose_name) or app_label
|
|
468
|
+
response = HttpResponse(pdf_output, content_type="application/pdf")
|
|
469
|
+
response["Content-Disposition"] = (
|
|
470
|
+
f'attachment; filename="{filename}-model-graph.pdf"'
|
|
471
|
+
)
|
|
472
|
+
return response
|
|
473
|
+
|
|
474
|
+
params = request.GET.copy()
|
|
475
|
+
if "format" in params:
|
|
476
|
+
del params["format"]
|
|
477
|
+
query_string = params.urlencode()
|
|
478
|
+
redirect_url = request.path
|
|
479
|
+
if query_string:
|
|
480
|
+
redirect_url = f"{request.path}?{query_string}"
|
|
481
|
+
return redirect(redirect_url)
|
|
482
|
+
|
|
483
|
+
if engine_path is None:
|
|
484
|
+
graph_error = _(
|
|
485
|
+
"Graphviz executables are required to render this diagram. Install Graphviz on the server and try again."
|
|
486
|
+
)
|
|
487
|
+
else:
|
|
488
|
+
try:
|
|
489
|
+
svg_output = graph.pipe(format="svg", encoding="utf-8")
|
|
490
|
+
except (ExecutableNotFound, CalledProcessError) as exc:
|
|
491
|
+
logger.warning(
|
|
492
|
+
"Graphviz rendering failed for admin model graph (engine=%s)",
|
|
493
|
+
graph_engine,
|
|
494
|
+
exc_info=exc,
|
|
495
|
+
)
|
|
496
|
+
graph_error = _(
|
|
497
|
+
"An error occurred while rendering the diagram. Check the server logs for details."
|
|
498
|
+
)
|
|
499
|
+
else:
|
|
500
|
+
svg_start = svg_output.find("<svg")
|
|
501
|
+
if svg_start != -1:
|
|
502
|
+
svg_output = svg_output[svg_start:]
|
|
503
|
+
label = _("%(app)s model diagram") % {"app": app_config.verbose_name}
|
|
504
|
+
graph_svg = svg_output.replace(
|
|
505
|
+
"<svg", f'<svg role="img" aria-label="{escape(label)}"', 1
|
|
506
|
+
)
|
|
507
|
+
if not graph_svg:
|
|
508
|
+
graph_error = _("Graphviz did not return any diagram output.")
|
|
509
|
+
|
|
510
|
+
model_links = []
|
|
511
|
+
for model in models:
|
|
512
|
+
opts = model._meta
|
|
513
|
+
try:
|
|
514
|
+
url = reverse(f"admin:{opts.app_label}_{opts.model_name}_changelist")
|
|
515
|
+
except NoReverseMatch:
|
|
516
|
+
url = ""
|
|
517
|
+
model_links.append(
|
|
518
|
+
{
|
|
519
|
+
"label": str(opts.verbose_name_plural),
|
|
520
|
+
"url": url,
|
|
521
|
+
}
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
download_params = request.GET.copy()
|
|
525
|
+
download_params["format"] = "pdf"
|
|
526
|
+
download_url = f"{request.path}?{download_params.urlencode()}"
|
|
527
|
+
|
|
528
|
+
context = admin.site.each_context(request)
|
|
529
|
+
context.update(
|
|
530
|
+
{
|
|
531
|
+
"app_label": app_label,
|
|
532
|
+
"app_verbose_name": app_config.verbose_name,
|
|
533
|
+
"graph_source": graph_source,
|
|
534
|
+
"graph_svg": graph_svg,
|
|
535
|
+
"graph_error": graph_error,
|
|
536
|
+
"models": model_links,
|
|
537
|
+
"title": _("%(app)s model graph") % {"app": app_config.verbose_name},
|
|
538
|
+
"download_url": download_url,
|
|
539
|
+
}
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
template_name = "admin/model_graph.html"
|
|
543
|
+
response = render(request, template_name, context)
|
|
544
|
+
if getattr(response, "context", None) is None:
|
|
545
|
+
response.context = context
|
|
546
|
+
if test_signals.template_rendered.receivers:
|
|
547
|
+
template = loader.get_template(template_name)
|
|
548
|
+
signal_context = context
|
|
549
|
+
if request is not None and "request" not in signal_context:
|
|
550
|
+
signal_context = {**context, "request": request}
|
|
551
|
+
test_signals.template_rendered.send(
|
|
552
|
+
sender=template.__class__,
|
|
553
|
+
template=template,
|
|
554
|
+
context=signal_context,
|
|
555
|
+
)
|
|
556
|
+
return response
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _locate_readme_document(role, doc: str | None, lang: str) -> SimpleNamespace:
|
|
560
|
+
app = (
|
|
561
|
+
Module.objects.filter(node_role=role, is_default=True)
|
|
562
|
+
.select_related("application")
|
|
563
|
+
.first()
|
|
564
|
+
)
|
|
565
|
+
app_slug = app.path.strip("/") if app else ""
|
|
566
|
+
root_base = Path(settings.BASE_DIR).resolve()
|
|
567
|
+
readme_base = (root_base / app_slug).resolve() if app_slug else root_base
|
|
568
|
+
candidates: list[Path] = []
|
|
569
|
+
|
|
570
|
+
if doc:
|
|
571
|
+
normalized = doc.strip().replace("\\", "/")
|
|
572
|
+
while normalized.startswith("./"):
|
|
573
|
+
normalized = normalized[2:]
|
|
574
|
+
normalized = normalized.lstrip("/")
|
|
575
|
+
if not normalized:
|
|
576
|
+
raise Http404("Document not found")
|
|
577
|
+
doc_path = Path(normalized)
|
|
578
|
+
if doc_path.is_absolute() or any(part == ".." for part in doc_path.parts):
|
|
579
|
+
raise Http404("Document not found")
|
|
580
|
+
|
|
581
|
+
relative_candidates: list[Path] = []
|
|
582
|
+
|
|
583
|
+
def add_candidate(path: Path) -> None:
|
|
584
|
+
if path not in relative_candidates:
|
|
585
|
+
relative_candidates.append(path)
|
|
586
|
+
|
|
587
|
+
add_candidate(doc_path)
|
|
588
|
+
if doc_path.suffix.lower() != ".md" or doc_path.suffix != ".md":
|
|
589
|
+
add_candidate(doc_path.with_suffix(".md"))
|
|
590
|
+
if doc_path.suffix.lower() != ".md":
|
|
591
|
+
add_candidate(doc_path / "README.md")
|
|
592
|
+
|
|
593
|
+
search_roots = [readme_base]
|
|
594
|
+
if readme_base != root_base:
|
|
595
|
+
search_roots.append(root_base)
|
|
596
|
+
|
|
597
|
+
for relative in relative_candidates:
|
|
598
|
+
for base in search_roots:
|
|
599
|
+
base_resolved = base.resolve()
|
|
600
|
+
candidate = (base_resolved / relative).resolve(strict=False)
|
|
601
|
+
try:
|
|
602
|
+
candidate.relative_to(base_resolved)
|
|
603
|
+
except ValueError:
|
|
604
|
+
continue
|
|
605
|
+
candidates.append(candidate)
|
|
606
|
+
else:
|
|
607
|
+
default_readme = readme_base / "README.md"
|
|
608
|
+
root_default: Path | None = None
|
|
609
|
+
if lang:
|
|
610
|
+
candidates.append(readme_base / f"README.{lang}.md")
|
|
611
|
+
short = lang.split("-")[0]
|
|
612
|
+
if short != lang:
|
|
613
|
+
candidates.append(readme_base / f"README.{short}.md")
|
|
614
|
+
if readme_base != root_base:
|
|
615
|
+
candidates.append(default_readme)
|
|
616
|
+
if lang:
|
|
617
|
+
candidates.append(root_base / f"README.{lang}.md")
|
|
618
|
+
short = lang.split("-")[0]
|
|
619
|
+
if short != lang:
|
|
620
|
+
candidates.append(root_base / f"README.{short}.md")
|
|
621
|
+
root_default = root_base / "README.md"
|
|
622
|
+
else:
|
|
623
|
+
root_default = default_readme
|
|
624
|
+
locale_base = root_base / "locale"
|
|
625
|
+
if locale_base.exists():
|
|
626
|
+
if lang:
|
|
627
|
+
candidates.append(locale_base / f"README.{lang}.md")
|
|
628
|
+
short = lang.split("-")[0]
|
|
629
|
+
if short != lang:
|
|
630
|
+
candidates.append(locale_base / f"README.{short}.md")
|
|
631
|
+
candidates.append(locale_base / "README.md")
|
|
632
|
+
if root_default is not None:
|
|
633
|
+
candidates.append(root_default)
|
|
634
|
+
|
|
635
|
+
readme_file = next((p for p in candidates if p.exists()), None)
|
|
636
|
+
if readme_file is None:
|
|
637
|
+
raise Http404("Document not found")
|
|
638
|
+
|
|
639
|
+
title = "README" if readme_file.name.startswith("README") else readme_file.stem
|
|
640
|
+
return SimpleNamespace(
|
|
641
|
+
file=readme_file,
|
|
642
|
+
title=title,
|
|
643
|
+
root_base=root_base,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _relative_readme_path(readme_file: Path, root_base: Path) -> str | None:
|
|
648
|
+
try:
|
|
649
|
+
return readme_file.relative_to(root_base).as_posix()
|
|
650
|
+
except ValueError:
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _render_readme(request, role, doc: str | None = None):
|
|
655
|
+
lang = getattr(request, "LANGUAGE_CODE", "")
|
|
656
|
+
lang = lang.replace("_", "-").lower()
|
|
657
|
+
document = _locate_readme_document(role, doc, lang)
|
|
658
|
+
text = document.file.read_text(encoding="utf-8")
|
|
659
|
+
html, toc_html = _render_markdown_with_toc(text)
|
|
660
|
+
relative_path = _relative_readme_path(document.file, document.root_base)
|
|
661
|
+
user = getattr(request, "user", None)
|
|
662
|
+
can_edit = bool(
|
|
663
|
+
relative_path
|
|
664
|
+
and user
|
|
665
|
+
and user.is_authenticated
|
|
666
|
+
and user.is_superuser
|
|
667
|
+
)
|
|
668
|
+
edit_url = None
|
|
669
|
+
if can_edit:
|
|
670
|
+
try:
|
|
671
|
+
edit_url = reverse("pages:readme-edit", kwargs={"doc": relative_path})
|
|
672
|
+
except NoReverseMatch:
|
|
673
|
+
edit_url = None
|
|
674
|
+
context = {
|
|
675
|
+
"content": html,
|
|
676
|
+
"title": document.title,
|
|
677
|
+
"toc": toc_html,
|
|
678
|
+
"page_url": request.build_absolute_uri(),
|
|
679
|
+
"edit_url": edit_url,
|
|
680
|
+
}
|
|
681
|
+
response = render(request, "pages/readme.html", context)
|
|
682
|
+
patch_vary_headers(response, ["Accept-Language", "Cookie"])
|
|
683
|
+
return response
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def readme_asset(request, source: str, asset: str):
|
|
687
|
+
source_normalized = (source or "").lower()
|
|
688
|
+
if source_normalized == "static":
|
|
689
|
+
file_path = _resolve_static_asset(asset)
|
|
690
|
+
elif source_normalized == "work":
|
|
691
|
+
file_path = _resolve_work_asset(getattr(request, "user", None), asset)
|
|
692
|
+
else:
|
|
693
|
+
raise Http404("Asset not found")
|
|
694
|
+
|
|
695
|
+
if not file_path.exists() or not file_path.is_file():
|
|
696
|
+
raise Http404("Asset not found")
|
|
697
|
+
|
|
698
|
+
extension = file_path.suffix.lower()
|
|
699
|
+
if extension not in ALLOWED_IMAGE_EXTENSIONS:
|
|
700
|
+
raise Http404("Asset not found")
|
|
701
|
+
|
|
702
|
+
try:
|
|
703
|
+
file_handle = file_path.open("rb")
|
|
704
|
+
except OSError as exc: # pragma: no cover - unexpected filesystem error
|
|
705
|
+
logger.warning("Unable to open asset %s", file_path, exc_info=exc)
|
|
706
|
+
raise Http404("Asset not found") from exc
|
|
707
|
+
|
|
708
|
+
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
|
709
|
+
response = FileResponse(file_handle, content_type=content_type)
|
|
710
|
+
try:
|
|
711
|
+
response["Content-Length"] = str(file_path.stat().st_size)
|
|
712
|
+
except OSError: # pragma: no cover - filesystem race
|
|
713
|
+
pass
|
|
714
|
+
|
|
715
|
+
if source_normalized == "work":
|
|
716
|
+
patch_cache_control(response, private=True, no_store=True)
|
|
717
|
+
patch_vary_headers(response, ["Cookie"])
|
|
718
|
+
else:
|
|
719
|
+
patch_cache_control(response, public=True, max_age=3600)
|
|
720
|
+
|
|
721
|
+
return response
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
class MarkdownDocumentForm(forms.Form):
|
|
725
|
+
content = forms.CharField(
|
|
726
|
+
widget=forms.Textarea(
|
|
727
|
+
attrs={
|
|
728
|
+
"class": "form-control",
|
|
729
|
+
"rows": 24,
|
|
730
|
+
"spellcheck": "false",
|
|
731
|
+
}
|
|
732
|
+
),
|
|
733
|
+
required=False,
|
|
734
|
+
strip=False,
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@landing("Home")
|
|
739
|
+
@never_cache
|
|
740
|
+
def index(request):
|
|
741
|
+
site = get_site(request)
|
|
742
|
+
if site:
|
|
743
|
+
try:
|
|
744
|
+
landing = site.badge.landing_override
|
|
745
|
+
except Exception:
|
|
746
|
+
landing = None
|
|
747
|
+
if landing:
|
|
748
|
+
return redirect(landing.path)
|
|
749
|
+
node = Node.get_local()
|
|
750
|
+
role = node.role if node else None
|
|
751
|
+
landing_filters = Q()
|
|
752
|
+
if role:
|
|
753
|
+
landing_filters |= Q(node_role=role)
|
|
754
|
+
user = getattr(request, "user", None)
|
|
755
|
+
if user and user.is_authenticated:
|
|
756
|
+
landing_filters |= Q(user=user)
|
|
757
|
+
user_group_ids = list(user.groups.values_list("pk", flat=True))
|
|
758
|
+
if user_group_ids:
|
|
759
|
+
security_group_ids = list(
|
|
760
|
+
SecurityGroup.objects.filter(pk__in=user_group_ids).values_list(
|
|
761
|
+
"pk", flat=True
|
|
762
|
+
)
|
|
763
|
+
)
|
|
764
|
+
if security_group_ids:
|
|
765
|
+
landing_filters |= Q(security_group_id__in=security_group_ids)
|
|
766
|
+
if landing_filters:
|
|
767
|
+
role_landing = (
|
|
768
|
+
RoleLanding.objects.filter(
|
|
769
|
+
landing_filters,
|
|
770
|
+
is_deleted=False,
|
|
771
|
+
landing__enabled=True,
|
|
772
|
+
landing__is_deleted=False,
|
|
773
|
+
)
|
|
774
|
+
.select_related("landing")
|
|
775
|
+
.order_by("-priority", "-pk")
|
|
776
|
+
.first()
|
|
777
|
+
)
|
|
778
|
+
if role_landing and role_landing.landing_id:
|
|
779
|
+
landing_obj = role_landing.landing
|
|
780
|
+
target_path = landing_obj.path
|
|
781
|
+
if target_path and target_path != request.path:
|
|
782
|
+
return redirect(target_path)
|
|
783
|
+
return _render_readme(request, role)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
@never_cache
|
|
787
|
+
def readme(request, doc=None):
|
|
788
|
+
node = Node.get_local()
|
|
789
|
+
role = node.role if node else None
|
|
790
|
+
return _render_readme(request, role, doc)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def readme_edit(request, doc):
|
|
794
|
+
user = getattr(request, "user", None)
|
|
795
|
+
if not (user and user.is_authenticated and user.is_superuser):
|
|
796
|
+
raise PermissionDenied
|
|
797
|
+
|
|
798
|
+
node = Node.get_local()
|
|
799
|
+
role = node.role if node else None
|
|
800
|
+
lang = getattr(request, "LANGUAGE_CODE", "")
|
|
801
|
+
lang = lang.replace("_", "-").lower()
|
|
802
|
+
document = _locate_readme_document(role, doc, lang)
|
|
803
|
+
relative_path = _relative_readme_path(document.file, document.root_base)
|
|
804
|
+
if relative_path:
|
|
805
|
+
read_url = reverse("pages:readme-document", kwargs={"doc": relative_path})
|
|
806
|
+
else:
|
|
807
|
+
read_url = reverse("pages:readme")
|
|
808
|
+
|
|
809
|
+
if request.method == "POST":
|
|
810
|
+
form = MarkdownDocumentForm(request.POST)
|
|
811
|
+
if form.is_valid():
|
|
812
|
+
content = form.cleaned_data["content"]
|
|
813
|
+
try:
|
|
814
|
+
document.file.write_text(content, encoding="utf-8")
|
|
815
|
+
except OSError:
|
|
816
|
+
logger.exception("Failed to update markdown document %s", document.file)
|
|
817
|
+
messages.error(
|
|
818
|
+
request,
|
|
819
|
+
_("Unable to save changes. Please try again."),
|
|
820
|
+
)
|
|
821
|
+
else:
|
|
822
|
+
messages.success(request, _("Document saved successfully."))
|
|
823
|
+
if relative_path:
|
|
824
|
+
return redirect("pages:readme-edit", doc=relative_path)
|
|
825
|
+
return redirect("pages:readme")
|
|
826
|
+
else:
|
|
827
|
+
try:
|
|
828
|
+
initial_text = document.file.read_text(encoding="utf-8")
|
|
829
|
+
except OSError:
|
|
830
|
+
logger.exception("Failed to read markdown document %s", document.file)
|
|
831
|
+
messages.error(request, _("Unable to load the document for editing."))
|
|
832
|
+
return redirect("pages:readme")
|
|
833
|
+
form = MarkdownDocumentForm(initial={"content": initial_text})
|
|
834
|
+
|
|
835
|
+
context = {
|
|
836
|
+
"form": form,
|
|
837
|
+
"title": document.title,
|
|
838
|
+
"relative_path": relative_path,
|
|
839
|
+
"read_url": read_url,
|
|
840
|
+
}
|
|
841
|
+
return render(request, "pages/readme_edit.html", context)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def sitemap(request):
|
|
845
|
+
site = get_site(request)
|
|
846
|
+
node = Node.get_local()
|
|
847
|
+
role = node.role if node else None
|
|
848
|
+
applications = Module.objects.filter(node_role=role)
|
|
849
|
+
base = request.build_absolute_uri("/").rstrip("/")
|
|
850
|
+
lines = [
|
|
851
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
852
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
853
|
+
]
|
|
854
|
+
seen = set()
|
|
855
|
+
for app in applications:
|
|
856
|
+
loc = f"{base}{app.path}"
|
|
857
|
+
if loc not in seen:
|
|
858
|
+
seen.add(loc)
|
|
859
|
+
lines.append(f" <url><loc>{loc}</loc></url>")
|
|
860
|
+
lines.append("</urlset>")
|
|
861
|
+
return HttpResponse("\n".join(lines), content_type="application/xml")
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
@landing("Package Releases")
|
|
865
|
+
@security_group_required("Release Managers")
|
|
866
|
+
def release_admin_redirect(request):
|
|
867
|
+
return redirect("admin:core_packagerelease_changelist")
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def release_checklist(request):
|
|
871
|
+
file_path = Path(settings.BASE_DIR) / "releases" / "release-checklist.md"
|
|
872
|
+
if not file_path.exists():
|
|
873
|
+
raise Http404("Release checklist not found")
|
|
874
|
+
text = file_path.read_text(encoding="utf-8")
|
|
875
|
+
html, toc_html = _render_markdown_with_toc(text)
|
|
876
|
+
context = {"content": html, "title": "Release Checklist", "toc": toc_html}
|
|
877
|
+
response = render(request, "pages/readme.html", context)
|
|
878
|
+
patch_vary_headers(response, ["Accept-Language", "Cookie"])
|
|
879
|
+
return response
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
class CustomLoginView(LoginView):
|
|
885
|
+
"""Login view that redirects staff to the admin."""
|
|
886
|
+
|
|
887
|
+
template_name = "pages/login.html"
|
|
888
|
+
form_class = AuthenticatorLoginForm
|
|
889
|
+
|
|
890
|
+
def dispatch(self, request, *args, **kwargs):
|
|
891
|
+
if request.user.is_authenticated:
|
|
892
|
+
return redirect(self.get_success_url())
|
|
893
|
+
return super().dispatch(request, *args, **kwargs)
|
|
894
|
+
|
|
895
|
+
def get_context_data(self, **kwargs):
|
|
896
|
+
context = super().get_context_data(**kwargs)
|
|
897
|
+
current_site = get_site(self.request)
|
|
898
|
+
redirect_target = self.request.GET.get(self.redirect_field_name)
|
|
899
|
+
restricted_notice = None
|
|
900
|
+
if redirect_target:
|
|
901
|
+
parsed_target = urlparse(redirect_target)
|
|
902
|
+
target_path = parsed_target.path or redirect_target
|
|
903
|
+
try:
|
|
904
|
+
simulator_path = reverse("cp-simulator")
|
|
905
|
+
except NoReverseMatch: # pragma: no cover - simulator may be uninstalled
|
|
906
|
+
simulator_path = None
|
|
907
|
+
if simulator_path and target_path.startswith(simulator_path):
|
|
908
|
+
restricted_notice = _(
|
|
909
|
+
"This page is reserved for members only. Please log in to continue."
|
|
910
|
+
)
|
|
911
|
+
redirect_value = context.get(self.redirect_field_name) or self.get_success_url()
|
|
912
|
+
context[self.redirect_field_name] = redirect_value
|
|
913
|
+
context["next"] = redirect_value
|
|
914
|
+
context.update(
|
|
915
|
+
{
|
|
916
|
+
"site": current_site,
|
|
917
|
+
"site_name": getattr(current_site, "name", ""),
|
|
918
|
+
"can_request_invite": mailer.can_send_email(),
|
|
919
|
+
"restricted_notice": restricted_notice,
|
|
920
|
+
}
|
|
921
|
+
)
|
|
922
|
+
return context
|
|
923
|
+
|
|
924
|
+
def get_success_url(self):
|
|
925
|
+
redirect_url = self.get_redirect_url()
|
|
926
|
+
if redirect_url:
|
|
927
|
+
return redirect_url
|
|
928
|
+
if self.request.user.is_staff:
|
|
929
|
+
return reverse("admin:index")
|
|
930
|
+
return "/"
|
|
931
|
+
|
|
932
|
+
def form_valid(self, form):
|
|
933
|
+
response = super().form_valid(form)
|
|
934
|
+
device = form.get_verified_device()
|
|
935
|
+
if device is not None:
|
|
936
|
+
otp_login(self.request, device)
|
|
937
|
+
return response
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
login_view = CustomLoginView.as_view()
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
@staff_member_required
|
|
944
|
+
def authenticator_setup(request):
|
|
945
|
+
"""Allow staff to enroll an authenticator app for TOTP logins."""
|
|
946
|
+
|
|
947
|
+
user = request.user
|
|
948
|
+
device_qs = TOTPDevice.objects.filter(user=user)
|
|
949
|
+
if TOTP_DEVICE_NAME:
|
|
950
|
+
device_qs = device_qs.filter(name=TOTP_DEVICE_NAME)
|
|
951
|
+
|
|
952
|
+
pending_device = device_qs.filter(confirmed=False).order_by("-id").first()
|
|
953
|
+
confirmed_device = device_qs.filter(confirmed=True).order_by("-id").first()
|
|
954
|
+
enrollment_form = AuthenticatorEnrollmentForm(device=pending_device)
|
|
955
|
+
|
|
956
|
+
if request.method == "POST":
|
|
957
|
+
action = request.POST.get("action")
|
|
958
|
+
if action == "generate":
|
|
959
|
+
device = pending_device or confirmed_device or TOTPDevice(user=user)
|
|
960
|
+
if TOTP_DEVICE_NAME:
|
|
961
|
+
device.name = TOTP_DEVICE_NAME
|
|
962
|
+
if device.pk is None:
|
|
963
|
+
device.save()
|
|
964
|
+
device.key = TOTPDevice._meta.get_field("key").get_default()
|
|
965
|
+
device.confirmed = False
|
|
966
|
+
device.drift = 0
|
|
967
|
+
device.last_t = -1
|
|
968
|
+
device.throttling_failure_count = 0
|
|
969
|
+
device.throttling_failure_timestamp = None
|
|
970
|
+
device.throttle_reset(commit=False)
|
|
971
|
+
device.save()
|
|
972
|
+
messages.success(
|
|
973
|
+
request,
|
|
974
|
+
_(
|
|
975
|
+
"Scan the QR code with your authenticator app, then "
|
|
976
|
+
"enter a code below to confirm enrollment."
|
|
977
|
+
),
|
|
978
|
+
)
|
|
979
|
+
return redirect("pages:authenticator-setup")
|
|
980
|
+
if action == "confirm" and pending_device is not None:
|
|
981
|
+
enrollment_form = AuthenticatorEnrollmentForm(
|
|
982
|
+
request.POST, device=pending_device
|
|
983
|
+
)
|
|
984
|
+
if enrollment_form.is_valid():
|
|
985
|
+
pending_device.confirmed = True
|
|
986
|
+
pending_device.save(update_fields=["confirmed"])
|
|
987
|
+
messages.success(
|
|
988
|
+
request,
|
|
989
|
+
_(
|
|
990
|
+
"Authenticator app confirmed. You can now log in "
|
|
991
|
+
"with codes from your device."
|
|
992
|
+
),
|
|
993
|
+
)
|
|
994
|
+
return redirect("pages:authenticator-setup")
|
|
995
|
+
if action == "remove":
|
|
996
|
+
if device_qs.exists():
|
|
997
|
+
device_qs.delete()
|
|
998
|
+
messages.success(
|
|
999
|
+
request,
|
|
1000
|
+
_(
|
|
1001
|
+
"Authenticator enrollment removed. Password logins "
|
|
1002
|
+
"remain available."
|
|
1003
|
+
),
|
|
1004
|
+
)
|
|
1005
|
+
return redirect("pages:authenticator-setup")
|
|
1006
|
+
|
|
1007
|
+
pending_device = device_qs.filter(confirmed=False).order_by("-id").first()
|
|
1008
|
+
confirmed_device = device_qs.filter(confirmed=True).order_by("-id").first()
|
|
1009
|
+
|
|
1010
|
+
qr_data_uri = None
|
|
1011
|
+
manual_key = None
|
|
1012
|
+
if pending_device is not None:
|
|
1013
|
+
config_url = pending_device.config_url
|
|
1014
|
+
qr = qrcode.QRCode(box_size=10, border=4)
|
|
1015
|
+
qr.add_data(config_url)
|
|
1016
|
+
qr.make(fit=True)
|
|
1017
|
+
image = qr.make_image(fill_color="black", back_color="white")
|
|
1018
|
+
buffer = io.BytesIO()
|
|
1019
|
+
image.save(buffer, format="PNG")
|
|
1020
|
+
qr_data_uri = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode(
|
|
1021
|
+
"ascii"
|
|
1022
|
+
)
|
|
1023
|
+
secret = pending_device.key or ""
|
|
1024
|
+
manual_key = " ".join(secret[i : i + 4] for i in range(0, len(secret), 4))
|
|
1025
|
+
|
|
1026
|
+
context = {
|
|
1027
|
+
"pending_device": pending_device,
|
|
1028
|
+
"confirmed_device": confirmed_device,
|
|
1029
|
+
"qr_data_uri": qr_data_uri,
|
|
1030
|
+
"manual_key": manual_key,
|
|
1031
|
+
"enrollment_form": enrollment_form,
|
|
1032
|
+
}
|
|
1033
|
+
return TemplateResponse(request, "pages/authenticator_setup.html", context)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
INVITATION_REQUEST_MIN_SUBMISSION_INTERVAL = datetime.timedelta(seconds=3)
|
|
1037
|
+
INVITATION_REQUEST_THROTTLE_LIMIT = 3
|
|
1038
|
+
INVITATION_REQUEST_THROTTLE_WINDOW = datetime.timedelta(hours=1)
|
|
1039
|
+
INVITATION_REQUEST_HONEYPOT_MESSAGE = _(
|
|
1040
|
+
"We could not process your request. Please try again."
|
|
1041
|
+
)
|
|
1042
|
+
INVITATION_REQUEST_TOO_FAST_MESSAGE = _(
|
|
1043
|
+
"That was a little too fast. Please wait a moment and try again."
|
|
1044
|
+
)
|
|
1045
|
+
INVITATION_REQUEST_TIMESTAMP_ERROR = _(
|
|
1046
|
+
"We could not verify your submission. Please reload the page and try again."
|
|
1047
|
+
)
|
|
1048
|
+
INVITATION_REQUEST_THROTTLE_MESSAGE = _(
|
|
1049
|
+
"We've already received a few requests. Please try again later."
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
class InvitationRequestForm(forms.Form):
|
|
1054
|
+
email = forms.EmailField()
|
|
1055
|
+
comment = forms.CharField(
|
|
1056
|
+
required=False, widget=forms.Textarea, label=_("Comment")
|
|
1057
|
+
)
|
|
1058
|
+
honeypot = forms.CharField(
|
|
1059
|
+
required=False,
|
|
1060
|
+
label=_("Leave blank"),
|
|
1061
|
+
widget=forms.TextInput(attrs={"autocomplete": "off"}),
|
|
1062
|
+
)
|
|
1063
|
+
timestamp = forms.DateTimeField(required=False, widget=forms.HiddenInput())
|
|
1064
|
+
|
|
1065
|
+
min_submission_interval = INVITATION_REQUEST_MIN_SUBMISSION_INTERVAL
|
|
1066
|
+
|
|
1067
|
+
def __init__(self, *args, **kwargs):
|
|
1068
|
+
super().__init__(*args, **kwargs)
|
|
1069
|
+
if not self.is_bound:
|
|
1070
|
+
self.fields["timestamp"].initial = timezone.now()
|
|
1071
|
+
self.fields["honeypot"].widget.attrs.setdefault("aria-hidden", "true")
|
|
1072
|
+
self.fields["honeypot"].widget.attrs.setdefault("tabindex", "-1")
|
|
1073
|
+
|
|
1074
|
+
def clean(self):
|
|
1075
|
+
cleaned = super().clean()
|
|
1076
|
+
|
|
1077
|
+
honeypot_value = cleaned.get("honeypot", "")
|
|
1078
|
+
if honeypot_value:
|
|
1079
|
+
raise forms.ValidationError(INVITATION_REQUEST_HONEYPOT_MESSAGE)
|
|
1080
|
+
|
|
1081
|
+
timestamp = cleaned.get("timestamp")
|
|
1082
|
+
if timestamp is None:
|
|
1083
|
+
cleaned["timestamp"] = timezone.now()
|
|
1084
|
+
return cleaned
|
|
1085
|
+
|
|
1086
|
+
now = timezone.now()
|
|
1087
|
+
if timestamp > now or (now - timestamp) < self.min_submission_interval:
|
|
1088
|
+
raise forms.ValidationError(INVITATION_REQUEST_TOO_FAST_MESSAGE)
|
|
1089
|
+
|
|
1090
|
+
return cleaned
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
@csrf_exempt
|
|
1094
|
+
@ensure_csrf_cookie
|
|
1095
|
+
def request_invite(request):
|
|
1096
|
+
form = InvitationRequestForm(request.POST if request.method == "POST" else None)
|
|
1097
|
+
sent = False
|
|
1098
|
+
if request.method == "POST" and form.is_valid():
|
|
1099
|
+
email = form.cleaned_data["email"]
|
|
1100
|
+
comment = form.cleaned_data.get("comment", "")
|
|
1101
|
+
ip_address = request.META.get("REMOTE_ADDR")
|
|
1102
|
+
throttle_filters = Q(email__iexact=email)
|
|
1103
|
+
if ip_address:
|
|
1104
|
+
throttle_filters |= Q(ip_address=ip_address)
|
|
1105
|
+
window_start = timezone.now() - INVITATION_REQUEST_THROTTLE_WINDOW
|
|
1106
|
+
recent_requests = InviteLead.objects.filter(
|
|
1107
|
+
throttle_filters, created_on__gte=window_start
|
|
1108
|
+
)
|
|
1109
|
+
if recent_requests.count() >= INVITATION_REQUEST_THROTTLE_LIMIT:
|
|
1110
|
+
form.add_error(None, INVITATION_REQUEST_THROTTLE_MESSAGE)
|
|
1111
|
+
else:
|
|
1112
|
+
mac_address = public_wifi.resolve_mac_address(ip_address)
|
|
1113
|
+
lead = InviteLead.objects.create(
|
|
1114
|
+
email=email,
|
|
1115
|
+
comment=comment,
|
|
1116
|
+
user=request.user if request.user.is_authenticated else None,
|
|
1117
|
+
path=request.path,
|
|
1118
|
+
referer=get_original_referer(request),
|
|
1119
|
+
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
|
1120
|
+
ip_address=ip_address,
|
|
1121
|
+
mac_address=mac_address or "",
|
|
1122
|
+
)
|
|
1123
|
+
logger.info("Invitation requested for %s", email)
|
|
1124
|
+
User = get_user_model()
|
|
1125
|
+
users = list(User.objects.filter(email__iexact=email))
|
|
1126
|
+
if not users:
|
|
1127
|
+
logger.warning("Invitation requested for unknown email %s", email)
|
|
1128
|
+
for user in users:
|
|
1129
|
+
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
|
1130
|
+
token = default_token_generator.make_token(user)
|
|
1131
|
+
link = request.build_absolute_uri(
|
|
1132
|
+
reverse("pages:invitation-login", args=[uid, token])
|
|
1133
|
+
)
|
|
1134
|
+
subject = _("Your invitation link")
|
|
1135
|
+
body = _("Use the following link to access your account: %(link)s") % {
|
|
1136
|
+
"link": link
|
|
1137
|
+
}
|
|
1138
|
+
try:
|
|
1139
|
+
node_error = None
|
|
1140
|
+
node = Node.get_local()
|
|
1141
|
+
outbox = getattr(node, "email_outbox", None) if node else None
|
|
1142
|
+
if node:
|
|
1143
|
+
try:
|
|
1144
|
+
result = node.send_mail(subject, body, [email])
|
|
1145
|
+
lead.sent_via_outbox = outbox
|
|
1146
|
+
except Exception as exc:
|
|
1147
|
+
node_error = exc
|
|
1148
|
+
lead.sent_via_outbox = None
|
|
1149
|
+
logger.exception(
|
|
1150
|
+
"Node send_mail failed, falling back to default backend"
|
|
1151
|
+
)
|
|
1152
|
+
result = mailer.send(
|
|
1153
|
+
subject, body, [email], settings.DEFAULT_FROM_EMAIL
|
|
1154
|
+
)
|
|
1155
|
+
else:
|
|
1156
|
+
result = mailer.send(
|
|
1157
|
+
subject, body, [email], settings.DEFAULT_FROM_EMAIL
|
|
1158
|
+
)
|
|
1159
|
+
lead.sent_via_outbox = None
|
|
1160
|
+
lead.sent_on = timezone.now()
|
|
1161
|
+
if node_error:
|
|
1162
|
+
lead.error = (
|
|
1163
|
+
f"Node email send failed: {node_error}. "
|
|
1164
|
+
"Invite was sent using default mail backend; ensure the "
|
|
1165
|
+
"node's email service is running or check its configuration."
|
|
1166
|
+
)
|
|
1167
|
+
else:
|
|
1168
|
+
lead.error = ""
|
|
1169
|
+
logger.info(
|
|
1170
|
+
"Invitation email sent to %s (user %s): %s", email, user.pk, result
|
|
1171
|
+
)
|
|
1172
|
+
except Exception as exc:
|
|
1173
|
+
lead.error = f"{exc}. Ensure the email service is reachable and settings are correct."
|
|
1174
|
+
lead.sent_via_outbox = None
|
|
1175
|
+
logger.exception("Failed to send invitation email to %s", email)
|
|
1176
|
+
if lead.sent_on or lead.error:
|
|
1177
|
+
lead.save(update_fields=["sent_on", "error", "sent_via_outbox"])
|
|
1178
|
+
sent = True
|
|
1179
|
+
return render(request, "pages/request_invite.html", {"form": form, "sent": sent})
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
class InvitationPasswordForm(forms.Form):
|
|
1183
|
+
new_password1 = forms.CharField(
|
|
1184
|
+
widget=forms.PasswordInput, required=False, label=_("New password")
|
|
1185
|
+
)
|
|
1186
|
+
new_password2 = forms.CharField(
|
|
1187
|
+
widget=forms.PasswordInput, required=False, label=_("Confirm password")
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
def clean(self):
|
|
1191
|
+
cleaned = super().clean()
|
|
1192
|
+
p1 = cleaned.get("new_password1")
|
|
1193
|
+
p2 = cleaned.get("new_password2")
|
|
1194
|
+
if p1 or p2:
|
|
1195
|
+
if not p1 or not p2 or p1 != p2:
|
|
1196
|
+
raise forms.ValidationError(_("Passwords do not match"))
|
|
1197
|
+
return cleaned
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def invitation_login(request, uidb64, token):
|
|
1201
|
+
User = get_user_model()
|
|
1202
|
+
try:
|
|
1203
|
+
uid = force_str(urlsafe_base64_decode(uidb64))
|
|
1204
|
+
user = User.objects.get(pk=uid)
|
|
1205
|
+
except Exception:
|
|
1206
|
+
user = None
|
|
1207
|
+
if user is None or not default_token_generator.check_token(user, token):
|
|
1208
|
+
return HttpResponse(_("Invalid invitation link"), status=400)
|
|
1209
|
+
form = InvitationPasswordForm(request.POST if request.method == "POST" else None)
|
|
1210
|
+
if request.method == "POST" and form.is_valid():
|
|
1211
|
+
password = form.cleaned_data.get("new_password1")
|
|
1212
|
+
if password:
|
|
1213
|
+
user.set_password(password)
|
|
1214
|
+
user.is_active = True
|
|
1215
|
+
user.save()
|
|
1216
|
+
node = Node.get_local()
|
|
1217
|
+
if node and node.has_feature("ap-router"):
|
|
1218
|
+
mac_address = public_wifi.resolve_mac_address(
|
|
1219
|
+
request.META.get("REMOTE_ADDR")
|
|
1220
|
+
)
|
|
1221
|
+
if not mac_address:
|
|
1222
|
+
mac_address = (
|
|
1223
|
+
InviteLead.objects.filter(email__iexact=user.email)
|
|
1224
|
+
.exclude(mac_address="")
|
|
1225
|
+
.order_by("-created_on")
|
|
1226
|
+
.values_list("mac_address", flat=True)
|
|
1227
|
+
.first()
|
|
1228
|
+
)
|
|
1229
|
+
if mac_address:
|
|
1230
|
+
public_wifi.grant_public_access(user, mac_address)
|
|
1231
|
+
login(request, user, backend="core.backends.LocalhostAdminBackend")
|
|
1232
|
+
return redirect(reverse("admin:index") if user.is_staff else "/")
|
|
1233
|
+
return render(request, "pages/invitation_login.html", {"form": form})
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
class ClientReportForm(forms.Form):
|
|
1237
|
+
PERIOD_CHOICES = [
|
|
1238
|
+
("range", _("Date range")),
|
|
1239
|
+
("week", _("Week")),
|
|
1240
|
+
("month", _("Month")),
|
|
1241
|
+
]
|
|
1242
|
+
RECURRENCE_CHOICES = ClientReportSchedule.PERIODICITY_CHOICES
|
|
1243
|
+
period = forms.ChoiceField(
|
|
1244
|
+
choices=PERIOD_CHOICES,
|
|
1245
|
+
widget=forms.RadioSelect,
|
|
1246
|
+
initial="range",
|
|
1247
|
+
help_text=_("Choose how the reporting window will be calculated."),
|
|
1248
|
+
)
|
|
1249
|
+
start = forms.DateField(
|
|
1250
|
+
label=_("Start date"),
|
|
1251
|
+
required=False,
|
|
1252
|
+
widget=forms.DateInput(attrs={"type": "date"}),
|
|
1253
|
+
help_text=_("First day included when using a custom date range."),
|
|
1254
|
+
)
|
|
1255
|
+
end = forms.DateField(
|
|
1256
|
+
label=_("End date"),
|
|
1257
|
+
required=False,
|
|
1258
|
+
widget=forms.DateInput(attrs={"type": "date"}),
|
|
1259
|
+
help_text=_("Last day included when using a custom date range."),
|
|
1260
|
+
)
|
|
1261
|
+
week = forms.CharField(
|
|
1262
|
+
label=_("Week"),
|
|
1263
|
+
required=False,
|
|
1264
|
+
widget=forms.TextInput(attrs={"type": "week"}),
|
|
1265
|
+
help_text=_("Generates the report for the ISO week that you select."),
|
|
1266
|
+
)
|
|
1267
|
+
month = forms.DateField(
|
|
1268
|
+
label=_("Month"),
|
|
1269
|
+
required=False,
|
|
1270
|
+
widget=forms.DateInput(attrs={"type": "month"}),
|
|
1271
|
+
input_formats=["%Y-%m"],
|
|
1272
|
+
help_text=_("Generates the report for the calendar month that you select."),
|
|
1273
|
+
)
|
|
1274
|
+
language = forms.ChoiceField(
|
|
1275
|
+
label=_("Report language"),
|
|
1276
|
+
choices=settings.LANGUAGES,
|
|
1277
|
+
help_text=_("Choose the language used for the generated report."),
|
|
1278
|
+
)
|
|
1279
|
+
title = forms.CharField(
|
|
1280
|
+
label=_("Report title"),
|
|
1281
|
+
required=False,
|
|
1282
|
+
max_length=200,
|
|
1283
|
+
help_text=_("Optional heading that replaces the default report title."),
|
|
1284
|
+
)
|
|
1285
|
+
chargers = forms.ModelMultipleChoiceField(
|
|
1286
|
+
label=_("Charge points"),
|
|
1287
|
+
queryset=Charger.objects.filter(connector_id__isnull=True)
|
|
1288
|
+
.order_by("display_name", "charger_id"),
|
|
1289
|
+
required=False,
|
|
1290
|
+
widget=forms.CheckboxSelectMultiple,
|
|
1291
|
+
help_text=_("Choose which charge points are included in the report."),
|
|
1292
|
+
)
|
|
1293
|
+
owner = forms.ModelChoiceField(
|
|
1294
|
+
queryset=get_user_model().objects.all(),
|
|
1295
|
+
required=False,
|
|
1296
|
+
help_text=_(
|
|
1297
|
+
"Sets who owns the report schedule and is listed as the requester."
|
|
1298
|
+
),
|
|
1299
|
+
)
|
|
1300
|
+
destinations = forms.CharField(
|
|
1301
|
+
label=_("Email destinations"),
|
|
1302
|
+
required=False,
|
|
1303
|
+
widget=forms.Textarea(attrs={"rows": 2}),
|
|
1304
|
+
help_text=_("Separate addresses with commas, whitespace, or new lines."),
|
|
1305
|
+
)
|
|
1306
|
+
recurrence = forms.ChoiceField(
|
|
1307
|
+
label=_("Recurrence"),
|
|
1308
|
+
choices=RECURRENCE_CHOICES,
|
|
1309
|
+
initial=ClientReportSchedule.PERIODICITY_NONE,
|
|
1310
|
+
help_text=_("Defines how often the report should be generated automatically."),
|
|
1311
|
+
)
|
|
1312
|
+
enable_emails = forms.BooleanField(
|
|
1313
|
+
label=_("Enable email delivery"),
|
|
1314
|
+
required=False,
|
|
1315
|
+
help_text=_("Send the report via email to the recipients listed above."),
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
def __init__(self, *args, request=None, **kwargs):
|
|
1319
|
+
self.request = request
|
|
1320
|
+
super().__init__(*args, **kwargs)
|
|
1321
|
+
if request and getattr(request, "user", None) and request.user.is_authenticated:
|
|
1322
|
+
self.fields["owner"].initial = request.user.pk
|
|
1323
|
+
self.fields["chargers"].widget.attrs["class"] = "charger-options"
|
|
1324
|
+
language_initial = ClientReport.default_language()
|
|
1325
|
+
if request:
|
|
1326
|
+
language_initial = ClientReport.normalize_language(
|
|
1327
|
+
getattr(request, "LANGUAGE_CODE", language_initial)
|
|
1328
|
+
)
|
|
1329
|
+
self.fields["language"].initial = language_initial
|
|
1330
|
+
|
|
1331
|
+
def clean(self):
|
|
1332
|
+
cleaned = super().clean()
|
|
1333
|
+
period = cleaned.get("period")
|
|
1334
|
+
if period == "range":
|
|
1335
|
+
if not cleaned.get("start") or not cleaned.get("end"):
|
|
1336
|
+
raise forms.ValidationError(_("Please provide start and end dates."))
|
|
1337
|
+
elif period == "week":
|
|
1338
|
+
week_str = cleaned.get("week")
|
|
1339
|
+
if not week_str:
|
|
1340
|
+
raise forms.ValidationError(_("Please select a week."))
|
|
1341
|
+
try:
|
|
1342
|
+
year_str, week_num_str = week_str.split("-W", 1)
|
|
1343
|
+
start = datetime.date.fromisocalendar(
|
|
1344
|
+
int(year_str), int(week_num_str), 1
|
|
1345
|
+
)
|
|
1346
|
+
except (TypeError, ValueError):
|
|
1347
|
+
raise forms.ValidationError(_("Please select a week."))
|
|
1348
|
+
cleaned["start"] = start
|
|
1349
|
+
cleaned["end"] = start + datetime.timedelta(days=6)
|
|
1350
|
+
elif period == "month":
|
|
1351
|
+
month_dt = cleaned.get("month")
|
|
1352
|
+
if not month_dt:
|
|
1353
|
+
raise forms.ValidationError(_("Please select a month."))
|
|
1354
|
+
start = month_dt.replace(day=1)
|
|
1355
|
+
last_day = calendar.monthrange(month_dt.year, month_dt.month)[1]
|
|
1356
|
+
cleaned["start"] = start
|
|
1357
|
+
cleaned["end"] = month_dt.replace(day=last_day)
|
|
1358
|
+
return cleaned
|
|
1359
|
+
|
|
1360
|
+
def clean_destinations(self):
|
|
1361
|
+
raw = self.cleaned_data.get("destinations", "")
|
|
1362
|
+
if not raw:
|
|
1363
|
+
return []
|
|
1364
|
+
validator = EmailValidator()
|
|
1365
|
+
seen: set[str] = set()
|
|
1366
|
+
emails: list[str] = []
|
|
1367
|
+
for part in re.split(r"[\s,]+", raw):
|
|
1368
|
+
candidate = part.strip()
|
|
1369
|
+
if not candidate:
|
|
1370
|
+
continue
|
|
1371
|
+
validator(candidate)
|
|
1372
|
+
key = candidate.lower()
|
|
1373
|
+
if key in seen:
|
|
1374
|
+
continue
|
|
1375
|
+
seen.add(key)
|
|
1376
|
+
emails.append(candidate)
|
|
1377
|
+
return emails
|
|
1378
|
+
|
|
1379
|
+
def clean_title(self):
|
|
1380
|
+
title = self.cleaned_data.get("title")
|
|
1381
|
+
return ClientReport.normalize_title(title)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
@live_update()
|
|
1385
|
+
def client_report(request):
|
|
1386
|
+
form = ClientReportForm(request.POST or None, request=request)
|
|
1387
|
+
report = None
|
|
1388
|
+
schedule = None
|
|
1389
|
+
if request.method == "POST":
|
|
1390
|
+
if not request.user.is_authenticated:
|
|
1391
|
+
form.is_valid() # Run validation to surface field errors alongside auth error.
|
|
1392
|
+
form.add_error(
|
|
1393
|
+
None, _("You must log in to generate consumer reports."),
|
|
1394
|
+
)
|
|
1395
|
+
elif form.is_valid():
|
|
1396
|
+
throttle_seconds = getattr(settings, "CLIENT_REPORT_THROTTLE_SECONDS", 60)
|
|
1397
|
+
throttle_keys = []
|
|
1398
|
+
if request.user.is_authenticated:
|
|
1399
|
+
throttle_keys.append(f"client-report:user:{request.user.pk}")
|
|
1400
|
+
remote_addr = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
1401
|
+
if remote_addr:
|
|
1402
|
+
remote_addr = remote_addr.split(",")[0].strip()
|
|
1403
|
+
remote_addr = remote_addr or request.META.get("REMOTE_ADDR")
|
|
1404
|
+
if remote_addr:
|
|
1405
|
+
throttle_keys.append(f"client-report:ip:{remote_addr}")
|
|
1406
|
+
|
|
1407
|
+
added_keys = []
|
|
1408
|
+
blocked = False
|
|
1409
|
+
for key in throttle_keys:
|
|
1410
|
+
if cache.add(key, timezone.now(), throttle_seconds):
|
|
1411
|
+
added_keys.append(key)
|
|
1412
|
+
else:
|
|
1413
|
+
blocked = True
|
|
1414
|
+
break
|
|
1415
|
+
|
|
1416
|
+
if blocked:
|
|
1417
|
+
for key in added_keys:
|
|
1418
|
+
cache.delete(key)
|
|
1419
|
+
form.add_error(
|
|
1420
|
+
None,
|
|
1421
|
+
_(
|
|
1422
|
+
"Consumer reports can only be generated periodically. Please wait before trying again."
|
|
1423
|
+
),
|
|
1424
|
+
)
|
|
1425
|
+
else:
|
|
1426
|
+
owner = form.cleaned_data.get("owner")
|
|
1427
|
+
if not owner and request.user.is_authenticated:
|
|
1428
|
+
owner = request.user
|
|
1429
|
+
enable_emails = form.cleaned_data.get("enable_emails", False)
|
|
1430
|
+
disable_emails = not enable_emails
|
|
1431
|
+
recipients = (
|
|
1432
|
+
form.cleaned_data.get("destinations") if enable_emails else []
|
|
1433
|
+
)
|
|
1434
|
+
chargers = list(form.cleaned_data.get("chargers") or [])
|
|
1435
|
+
language = form.cleaned_data.get("language")
|
|
1436
|
+
title = form.cleaned_data.get("title")
|
|
1437
|
+
report = ClientReport.generate(
|
|
1438
|
+
form.cleaned_data["start"],
|
|
1439
|
+
form.cleaned_data["end"],
|
|
1440
|
+
owner=owner,
|
|
1441
|
+
recipients=recipients,
|
|
1442
|
+
disable_emails=disable_emails,
|
|
1443
|
+
chargers=chargers,
|
|
1444
|
+
language=language,
|
|
1445
|
+
title=title,
|
|
1446
|
+
)
|
|
1447
|
+
report.store_local_copy()
|
|
1448
|
+
if chargers:
|
|
1449
|
+
report.chargers.set(chargers)
|
|
1450
|
+
if enable_emails and recipients:
|
|
1451
|
+
delivered = report.send_delivery(
|
|
1452
|
+
to=recipients,
|
|
1453
|
+
cc=[],
|
|
1454
|
+
outbox=ClientReport.resolve_outbox_for_owner(owner),
|
|
1455
|
+
reply_to=ClientReport.resolve_reply_to_for_owner(owner),
|
|
1456
|
+
)
|
|
1457
|
+
if delivered:
|
|
1458
|
+
report.recipients = delivered
|
|
1459
|
+
report.save(update_fields=["recipients"])
|
|
1460
|
+
messages.success(
|
|
1461
|
+
request,
|
|
1462
|
+
_("Consumer report emailed to the selected recipients."),
|
|
1463
|
+
)
|
|
1464
|
+
recurrence = form.cleaned_data.get("recurrence")
|
|
1465
|
+
if recurrence and recurrence != ClientReportSchedule.PERIODICITY_NONE:
|
|
1466
|
+
schedule = ClientReportSchedule.objects.create(
|
|
1467
|
+
owner=owner,
|
|
1468
|
+
created_by=request.user if request.user.is_authenticated else None,
|
|
1469
|
+
periodicity=recurrence,
|
|
1470
|
+
email_recipients=recipients,
|
|
1471
|
+
disable_emails=disable_emails,
|
|
1472
|
+
language=language,
|
|
1473
|
+
title=title,
|
|
1474
|
+
)
|
|
1475
|
+
if chargers:
|
|
1476
|
+
schedule.chargers.set(chargers)
|
|
1477
|
+
report.schedule = schedule
|
|
1478
|
+
report.save(update_fields=["schedule"])
|
|
1479
|
+
messages.success(
|
|
1480
|
+
request,
|
|
1481
|
+
_(
|
|
1482
|
+
"Consumer report schedule created; future reports will be generated automatically."
|
|
1483
|
+
),
|
|
1484
|
+
)
|
|
1485
|
+
if disable_emails:
|
|
1486
|
+
messages.success(
|
|
1487
|
+
request,
|
|
1488
|
+
_(
|
|
1489
|
+
"Consumer report generated. The download will begin automatically."
|
|
1490
|
+
),
|
|
1491
|
+
)
|
|
1492
|
+
redirect_url = f"{reverse('pages:client-report')}?download={report.pk}"
|
|
1493
|
+
return HttpResponseRedirect(redirect_url)
|
|
1494
|
+
download_url = None
|
|
1495
|
+
download_param = request.GET.get("download")
|
|
1496
|
+
if download_param and request.user.is_authenticated:
|
|
1497
|
+
try:
|
|
1498
|
+
download_id = int(download_param)
|
|
1499
|
+
except (TypeError, ValueError):
|
|
1500
|
+
download_id = None
|
|
1501
|
+
if download_id:
|
|
1502
|
+
download_url = reverse(
|
|
1503
|
+
"pages:client-report-download", args=[download_id]
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
try:
|
|
1507
|
+
login_url = reverse("pages:login")
|
|
1508
|
+
except NoReverseMatch:
|
|
1509
|
+
try:
|
|
1510
|
+
login_url = reverse("login")
|
|
1511
|
+
except NoReverseMatch:
|
|
1512
|
+
login_url = getattr(settings, "LOGIN_URL", None)
|
|
1513
|
+
|
|
1514
|
+
context = {
|
|
1515
|
+
"form": form,
|
|
1516
|
+
"report": report,
|
|
1517
|
+
"schedule": schedule,
|
|
1518
|
+
"login_url": login_url,
|
|
1519
|
+
"download_url": download_url,
|
|
1520
|
+
}
|
|
1521
|
+
return render(request, "pages/client_report.html", context)
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
@login_required
|
|
1525
|
+
def client_report_download(request, report_id: int):
|
|
1526
|
+
report = get_object_or_404(ClientReport, pk=report_id)
|
|
1527
|
+
if not request.user.is_staff and report.owner_id != request.user.pk:
|
|
1528
|
+
return HttpResponseForbidden(
|
|
1529
|
+
_("You do not have permission to download this report.")
|
|
1530
|
+
)
|
|
1531
|
+
pdf_path = report.ensure_pdf()
|
|
1532
|
+
if not pdf_path.exists():
|
|
1533
|
+
raise Http404(_("Report file unavailable."))
|
|
1534
|
+
filename = f"consumer-report-{report.start_date}-{report.end_date}.pdf"
|
|
1535
|
+
response = FileResponse(pdf_path.open("rb"), content_type="application/pdf")
|
|
1536
|
+
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
1537
|
+
return response
|
|
1538
|
+
def _get_request_language_code(request) -> str:
|
|
1539
|
+
language_code = ""
|
|
1540
|
+
if hasattr(request, "session"):
|
|
1541
|
+
language_code = request.session.get(LANGUAGE_SESSION_KEY, "")
|
|
1542
|
+
if not language_code:
|
|
1543
|
+
cookie_name = getattr(settings, "LANGUAGE_COOKIE_NAME", "django_language")
|
|
1544
|
+
language_code = request.COOKIES.get(cookie_name, "")
|
|
1545
|
+
if not language_code:
|
|
1546
|
+
language_code = getattr(request, "LANGUAGE_CODE", "") or ""
|
|
1547
|
+
if not language_code:
|
|
1548
|
+
language_code = get_language() or ""
|
|
1549
|
+
|
|
1550
|
+
language_code = language_code.strip()
|
|
1551
|
+
if not language_code:
|
|
1552
|
+
return ""
|
|
1553
|
+
|
|
1554
|
+
return language_code.replace("_", "-").lower()[:15]
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
@require_POST
|
|
1558
|
+
def submit_user_story(request):
|
|
1559
|
+
throttle_seconds = getattr(settings, "USER_STORY_THROTTLE_SECONDS", 300)
|
|
1560
|
+
client_ip = _get_client_ip(request)
|
|
1561
|
+
cache_key = None
|
|
1562
|
+
|
|
1563
|
+
if throttle_seconds:
|
|
1564
|
+
cache_key = f"user-story:ip:{client_ip or 'unknown'}"
|
|
1565
|
+
if not cache.add(cache_key, timezone.now(), throttle_seconds):
|
|
1566
|
+
minutes = throttle_seconds // 60
|
|
1567
|
+
if throttle_seconds % 60:
|
|
1568
|
+
minutes += 1
|
|
1569
|
+
error_message = _(
|
|
1570
|
+
"You can only submit feedback once every %(minutes)s minutes."
|
|
1571
|
+
) % {"minutes": minutes or 1}
|
|
1572
|
+
return JsonResponse(
|
|
1573
|
+
{"success": False, "errors": {"__all__": [error_message]}},
|
|
1574
|
+
status=429,
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
data = request.POST.copy()
|
|
1578
|
+
if request.user.is_authenticated:
|
|
1579
|
+
data["name"] = request.user.get_username()[:40]
|
|
1580
|
+
if not data.get("path"):
|
|
1581
|
+
data["path"] = request.get_full_path()
|
|
1582
|
+
|
|
1583
|
+
form = UserStoryForm(data, user=request.user)
|
|
1584
|
+
if request.user.is_authenticated:
|
|
1585
|
+
form.instance.user = request.user
|
|
1586
|
+
|
|
1587
|
+
if form.is_valid():
|
|
1588
|
+
story = form.save(commit=False)
|
|
1589
|
+
if request.user.is_authenticated:
|
|
1590
|
+
story.user = request.user
|
|
1591
|
+
story.owner = request.user
|
|
1592
|
+
story.name = request.user.get_username()[:40]
|
|
1593
|
+
if not story.name:
|
|
1594
|
+
story.name = str(_("Anonymous"))[:40]
|
|
1595
|
+
story.path = (story.path or request.get_full_path())[:500]
|
|
1596
|
+
story.referer = get_original_referer(request)
|
|
1597
|
+
story.user_agent = request.META.get("HTTP_USER_AGENT", "")
|
|
1598
|
+
story.ip_address = client_ip or None
|
|
1599
|
+
story.is_user_data = True
|
|
1600
|
+
language_code = _get_request_language_code(request)
|
|
1601
|
+
if language_code:
|
|
1602
|
+
story.language_code = language_code
|
|
1603
|
+
story.save()
|
|
1604
|
+
if request.user.is_authenticated and request.user.is_superuser:
|
|
1605
|
+
comment_text = (story.comments or "").strip()
|
|
1606
|
+
prefix = "Triage "
|
|
1607
|
+
request_field = Todo._meta.get_field("request")
|
|
1608
|
+
available_length = max(request_field.max_length - len(prefix), 0)
|
|
1609
|
+
if available_length > 0 and comment_text:
|
|
1610
|
+
summary = Truncator(comment_text).chars(
|
|
1611
|
+
available_length, truncate="…"
|
|
1612
|
+
)
|
|
1613
|
+
else:
|
|
1614
|
+
summary = comment_text[:available_length]
|
|
1615
|
+
todo_request = f"{prefix}{summary}".strip()
|
|
1616
|
+
user_is_authenticated = request.user.is_authenticated
|
|
1617
|
+
node = Node.get_local()
|
|
1618
|
+
existing_todo = (
|
|
1619
|
+
Todo.objects.filter(request__iexact=todo_request, is_deleted=False)
|
|
1620
|
+
.order_by("pk")
|
|
1621
|
+
.first()
|
|
1622
|
+
)
|
|
1623
|
+
if existing_todo:
|
|
1624
|
+
update_fields: set[str] = set()
|
|
1625
|
+
if node and existing_todo.origin_node_id != node.pk:
|
|
1626
|
+
existing_todo.origin_node = node
|
|
1627
|
+
update_fields.add("origin_node")
|
|
1628
|
+
if existing_todo.original_user_id != request.user.pk:
|
|
1629
|
+
existing_todo.original_user = request.user
|
|
1630
|
+
update_fields.add("original_user")
|
|
1631
|
+
if (
|
|
1632
|
+
existing_todo.original_user_is_authenticated
|
|
1633
|
+
!= user_is_authenticated
|
|
1634
|
+
):
|
|
1635
|
+
existing_todo.original_user_is_authenticated = (
|
|
1636
|
+
user_is_authenticated
|
|
1637
|
+
)
|
|
1638
|
+
update_fields.add("original_user_is_authenticated")
|
|
1639
|
+
if not existing_todo.is_user_data:
|
|
1640
|
+
existing_todo.is_user_data = True
|
|
1641
|
+
update_fields.add("is_user_data")
|
|
1642
|
+
if update_fields:
|
|
1643
|
+
existing_todo.save(update_fields=tuple(update_fields))
|
|
1644
|
+
else:
|
|
1645
|
+
Todo.objects.create(
|
|
1646
|
+
request=todo_request,
|
|
1647
|
+
origin_node=node,
|
|
1648
|
+
original_user=request.user,
|
|
1649
|
+
original_user_is_authenticated=user_is_authenticated,
|
|
1650
|
+
is_user_data=True,
|
|
1651
|
+
)
|
|
1652
|
+
if story.take_screenshot:
|
|
1653
|
+
screenshot_url = request.META.get("HTTP_REFERER", "")
|
|
1654
|
+
parsed = urlparse(screenshot_url)
|
|
1655
|
+
if not (parsed.scheme and parsed.netloc):
|
|
1656
|
+
target_path = story.path or request.get_full_path() or "/"
|
|
1657
|
+
screenshot_url = request.build_absolute_uri(target_path)
|
|
1658
|
+
try:
|
|
1659
|
+
screenshot_path = capture_screenshot(screenshot_url)
|
|
1660
|
+
except Exception: # pragma: no cover - best effort capture
|
|
1661
|
+
logger.exception("Failed to capture screenshot for user story %s", story.pk)
|
|
1662
|
+
else:
|
|
1663
|
+
try:
|
|
1664
|
+
sample = save_screenshot(
|
|
1665
|
+
screenshot_path,
|
|
1666
|
+
method="USER_STORY",
|
|
1667
|
+
user=story.user if story.user_id else None,
|
|
1668
|
+
link_duplicates=True,
|
|
1669
|
+
)
|
|
1670
|
+
except Exception: # pragma: no cover - best effort persistence
|
|
1671
|
+
logger.exception(
|
|
1672
|
+
"Failed to persist screenshot for user story %s", story.pk
|
|
1673
|
+
)
|
|
1674
|
+
else:
|
|
1675
|
+
if sample is not None:
|
|
1676
|
+
story.screenshot = sample
|
|
1677
|
+
story.save(update_fields=["screenshot"])
|
|
1678
|
+
return JsonResponse({"success": True})
|
|
1679
|
+
|
|
1680
|
+
return JsonResponse({"success": False, "errors": form.errors}, status=400)
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def csrf_failure(request, reason=""):
|
|
1684
|
+
"""Custom CSRF failure view with a friendly message."""
|
|
1685
|
+
logger.warning("CSRF failure on %s: %s", request.path, reason)
|
|
1686
|
+
return render(request, "pages/csrf_failure.html", status=403)
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
def _admin_context(request):
|
|
1690
|
+
context = admin.site.each_context(request)
|
|
1691
|
+
if not context.get("has_permission"):
|
|
1692
|
+
rf = RequestFactory()
|
|
1693
|
+
mock_request = rf.get(request.path)
|
|
1694
|
+
mock_request.user = SimpleNamespace(
|
|
1695
|
+
is_active=True,
|
|
1696
|
+
is_staff=True,
|
|
1697
|
+
is_superuser=True,
|
|
1698
|
+
has_perm=lambda perm, obj=None: True,
|
|
1699
|
+
has_module_perms=lambda app_label: True,
|
|
1700
|
+
)
|
|
1701
|
+
context["available_apps"] = admin.site.get_app_list(mock_request)
|
|
1702
|
+
context["has_permission"] = True
|
|
1703
|
+
return context
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
def admin_manual_list(request):
|
|
1707
|
+
manuals = UserManual.objects.order_by("title")
|
|
1708
|
+
context = _admin_context(request)
|
|
1709
|
+
context["manuals"] = manuals
|
|
1710
|
+
return render(request, "admin_doc/manuals.html", context)
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def admin_manual_detail(request, slug):
|
|
1714
|
+
manual = get_object_or_404(UserManual, slug=slug)
|
|
1715
|
+
context = _admin_context(request)
|
|
1716
|
+
context["manual"] = manual
|
|
1717
|
+
return render(request, "admin_doc/manual_detail.html", context)
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def manual_pdf(request, slug):
|
|
1721
|
+
manual = get_object_or_404(UserManual, slug=slug)
|
|
1722
|
+
pdf_data = base64.b64decode(manual.content_pdf)
|
|
1723
|
+
response = HttpResponse(pdf_data, content_type="application/pdf")
|
|
1724
|
+
response["Content-Disposition"] = f'attachment; filename="{manual.slug}.pdf"'
|
|
1725
|
+
return response
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
@landing(_("Manuals"))
|
|
1729
|
+
def manual_list(request):
|
|
1730
|
+
manuals = UserManual.objects.order_by("title")
|
|
1731
|
+
return render(request, "pages/manual_list.html", {"manuals": manuals})
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def manual_detail(request, slug):
|
|
1735
|
+
manual = get_object_or_404(UserManual, slug=slug)
|
|
1736
|
+
return render(request, "pages/manual_detail.html", {"manual": manual})
|