varanus 0.1.0.dev0__tar.gz → 0.1.0.dev2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/PKG-INFO +2 -2
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/pyproject.toml +2 -2
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/transport/http.py +13 -3
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/events.py +10 -0
- varanus-0.1.0.dev2/src/varanus/server/asgi.py +7 -0
- varanus-0.1.0.dev2/src/varanus/server/migrations/0002_context_node_error_node_log_node_metric_node_and_more.py +79 -0
- varanus-0.1.0.dev2/src/varanus/server/migrations/0003_alter_log_level.py +28 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/models.py +43 -7
- varanus-0.1.0.dev2/src/varanus/server/search/__init__.py +9 -0
- varanus-0.1.0.dev2/src/varanus/server/search/base.py +118 -0
- varanus-0.1.0.dev2/src/varanus/server/search/date.py +33 -0
- varanus-0.1.0.dev2/src/varanus/server/search/facet.py +63 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/settings.py +2 -2
- varanus-0.1.0.dev2/src/varanus/server/static/css/varanus.css +4 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/base.html +3 -1
- varanus-0.1.0.dev2/src/varanus/server/templates/search/daterange.html +10 -0
- varanus-0.1.0.dev2/src/varanus/server/templates/search/multifacet.html +11 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/base.html +15 -12
- varanus-0.1.0.dev2/src/varanus/server/templates/site/details/environment_nodes.html +6 -0
- varanus-0.1.0.dev2/src/varanus/server/templates/site/details/error.html +49 -0
- varanus-0.1.0.dev2/src/varanus/server/templates/site/details/log.html +36 -0
- varanus-0.1.0.dev2/src/varanus/server/templates/site/details/metric.html +40 -0
- varanus-0.1.0.dev2/src/varanus/server/templates/site/details/node.html +44 -0
- varanus-0.1.0.dev2/src/varanus/server/templates/site/details/node_environments.html +6 -0
- varanus-0.1.0.dev2/src/varanus/server/templates/site/details/query.html +31 -0
- varanus-0.1.0.dev2/src/varanus/server/templates/site/details/request.html +136 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/errors.html +6 -4
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/logs.html +7 -5
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/metrics.html +6 -4
- varanus-0.1.0.dev2/src/varanus/server/templates/site/overview.html +56 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/queries.html +8 -4
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/requests.html +6 -4
- varanus-0.1.0.dev2/src/varanus/server/templatetags/varanus.py +13 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/urls.py +27 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/views/api.py +7 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/views/base.py +1 -1
- varanus-0.1.0.dev2/src/varanus/server/views/site.py +165 -0
- varanus-0.1.0.dev0/src/varanus/server/asgi.py +0 -18
- varanus-0.1.0.dev0/src/varanus/server/management/commands/runserver.py +0 -15
- varanus-0.1.0.dev0/src/varanus/server/search.py +0 -120
- varanus-0.1.0.dev0/src/varanus/server/templates/site/overview.html +0 -14
- varanus-0.1.0.dev0/src/varanus/server/views/site.py +0 -69
- varanus-0.1.0.dev0/src/varanus/server/websockets.py +0 -13
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/README.md +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/__init__.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/__init__.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/apps.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/client.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/context.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/loggers.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/middleware.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/transport/__init__.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/transport/base.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/transport/database.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/__init__.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/__main__.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/admin.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/apps.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/context_processors.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/management/__init__.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/management/commands/__init__.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/management/commands/migrateall.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/middleware.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/migrations/0001_initial.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/migrations/__init__.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/router.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/static/js/varanus.js +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/dashboard.html +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/registration/login.html +0 -0
- {varanus-0.1.0.dev0/src/varanus/server/views → varanus-0.1.0.dev2/src/varanus/server/templatetags}/__init__.py +0 -0
- /varanus-0.1.0.dev0/src/varanus/server/static/css/varanus.css → /varanus-0.1.0.dev2/src/varanus/server/views/__init__.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/views/dashboard.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/wsgi.py +0 -0
- {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/utils.py +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: varanus
|
|
3
|
-
Version: 0.1.0.
|
|
3
|
+
Version: 0.1.0.dev2
|
|
4
4
|
Summary: Django application monitoring.
|
|
5
5
|
Requires-Dist: httpx>=0.27.0
|
|
6
6
|
Requires-Dist: msgspec>=0.19.0
|
|
7
7
|
Requires-Dist: cconf>=1.0.0 ; extra == 'server'
|
|
8
8
|
Requires-Dist: django~=5.2.0 ; extra == 'server'
|
|
9
9
|
Requires-Dist: django-passkey-auth>=0.2.0 ; extra == 'server'
|
|
10
|
+
Requires-Dist: granian>=2.4.2 ; extra == 'server'
|
|
10
11
|
Requires-Dist: psycopg[binary]>=3.2.1 ; extra == 'server'
|
|
11
|
-
Requires-Dist: uvicorn>=0.30.6 ; extra == 'server'
|
|
12
12
|
Requires-Dist: websockets>=13.0 ; extra == 'server'
|
|
13
13
|
Requires-Dist: whitenoise>=6.7.0 ; extra == 'server'
|
|
14
14
|
Requires-Python: >=3.11
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "varanus"
|
|
3
|
-
version = "0.1.0.
|
|
3
|
+
version = "0.1.0.dev2"
|
|
4
4
|
description = "Django application monitoring."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -14,8 +14,8 @@ server = [
|
|
|
14
14
|
"cconf>=1.0.0",
|
|
15
15
|
"django~=5.2.0",
|
|
16
16
|
"django-passkey-auth>=0.2.0",
|
|
17
|
+
"granian>=2.4.2",
|
|
17
18
|
"psycopg[binary]>=3.2.1",
|
|
18
|
-
"uvicorn>=0.30.6",
|
|
19
19
|
"websockets>=13.0",
|
|
20
20
|
"whitenoise>=6.7.0",
|
|
21
21
|
]
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
from typing import Any
|
|
1
3
|
from urllib.parse import SplitResult
|
|
2
4
|
|
|
3
5
|
import httpx
|
|
@@ -17,11 +19,19 @@ class HttpTransport(BaseTransport):
|
|
|
17
19
|
headers={
|
|
18
20
|
"X-Varanus-Key": url.username or "",
|
|
19
21
|
"X-Varanus-Environment": environment or "",
|
|
20
|
-
|
|
22
|
+
"X-Varanus-Node": platform.node(),
|
|
23
|
+
},
|
|
24
|
+
timeout=1.0,
|
|
21
25
|
)
|
|
22
26
|
|
|
27
|
+
def request(self, url: str, obj: Any):
|
|
28
|
+
try:
|
|
29
|
+
self.client.post(url, content=msgspec.json.encode(obj))
|
|
30
|
+
except Exception as ex:
|
|
31
|
+
print(f"error sending to {url}: {ex}")
|
|
32
|
+
|
|
23
33
|
def ping(self, info: events.NodeInfo):
|
|
24
|
-
self.
|
|
34
|
+
self.request(self.ping_url, info)
|
|
25
35
|
|
|
26
36
|
def send(self, event: events.Context):
|
|
27
|
-
self.
|
|
37
|
+
self.request(self.event_url, event)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import inspect
|
|
1
2
|
import logging
|
|
2
3
|
from datetime import datetime, timezone
|
|
3
4
|
|
|
@@ -25,6 +26,7 @@ class ErrorLine(Struct):
|
|
|
25
26
|
lineno: int | None
|
|
26
27
|
function: str | None
|
|
27
28
|
module: str | None
|
|
29
|
+
linesrc: str | None
|
|
28
30
|
locals: dict[str, str]
|
|
29
31
|
|
|
30
32
|
|
|
@@ -52,6 +54,13 @@ class Error(Event):
|
|
|
52
54
|
abs_path = f_code.co_filename if f_code else None
|
|
53
55
|
function = f_code.co_name if f_code else None
|
|
54
56
|
lineno = getattr(tb, "tb_lineno", None)
|
|
57
|
+
linesrc = None
|
|
58
|
+
if f_code and lineno:
|
|
59
|
+
try:
|
|
60
|
+
source, start = inspect.getsourcelines(f_code)
|
|
61
|
+
linesrc = source[lineno - start].strip()
|
|
62
|
+
except (OSError, TypeError):
|
|
63
|
+
pass
|
|
55
64
|
module = f_globals.get("__name__", "")
|
|
56
65
|
lines.append(
|
|
57
66
|
ErrorLine(
|
|
@@ -59,6 +68,7 @@ class Error(Event):
|
|
|
59
68
|
lineno=lineno,
|
|
60
69
|
function=function,
|
|
61
70
|
module=module,
|
|
71
|
+
linesrc=linesrc,
|
|
62
72
|
locals={name: repr(val) for name, val in f_locals.items()},
|
|
63
73
|
)
|
|
64
74
|
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Generated by Django 5.2.8 on 2025-11-16 01:38
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
dependencies = [
|
|
9
|
+
("varanus", "0001_initial"),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name="context",
|
|
15
|
+
name="node",
|
|
16
|
+
field=models.ForeignKey(
|
|
17
|
+
blank=True,
|
|
18
|
+
null=True,
|
|
19
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
20
|
+
related_name="%(class)ss",
|
|
21
|
+
to="varanus.node",
|
|
22
|
+
),
|
|
23
|
+
),
|
|
24
|
+
migrations.AddField(
|
|
25
|
+
model_name="error",
|
|
26
|
+
name="node",
|
|
27
|
+
field=models.ForeignKey(
|
|
28
|
+
blank=True,
|
|
29
|
+
null=True,
|
|
30
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
31
|
+
related_name="%(class)ss",
|
|
32
|
+
to="varanus.node",
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
migrations.AddField(
|
|
36
|
+
model_name="log",
|
|
37
|
+
name="node",
|
|
38
|
+
field=models.ForeignKey(
|
|
39
|
+
blank=True,
|
|
40
|
+
null=True,
|
|
41
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
42
|
+
related_name="%(class)ss",
|
|
43
|
+
to="varanus.node",
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
migrations.AddField(
|
|
47
|
+
model_name="metric",
|
|
48
|
+
name="node",
|
|
49
|
+
field=models.ForeignKey(
|
|
50
|
+
blank=True,
|
|
51
|
+
null=True,
|
|
52
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
53
|
+
related_name="%(class)ss",
|
|
54
|
+
to="varanus.node",
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
migrations.AddField(
|
|
58
|
+
model_name="query",
|
|
59
|
+
name="node",
|
|
60
|
+
field=models.ForeignKey(
|
|
61
|
+
blank=True,
|
|
62
|
+
null=True,
|
|
63
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
64
|
+
related_name="%(class)ss",
|
|
65
|
+
to="varanus.node",
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
migrations.AddField(
|
|
69
|
+
model_name="request",
|
|
70
|
+
name="node",
|
|
71
|
+
field=models.ForeignKey(
|
|
72
|
+
blank=True,
|
|
73
|
+
null=True,
|
|
74
|
+
on_delete=django.db.models.deletion.SET_NULL,
|
|
75
|
+
related_name="%(class)ss",
|
|
76
|
+
to="varanus.node",
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Generated by Django 5.2.8 on 2025-11-17 19:51
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
dependencies = [
|
|
8
|
+
("varanus", "0002_context_node_error_node_log_node_metric_node_and_more"),
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
operations = [
|
|
12
|
+
migrations.AlterField(
|
|
13
|
+
model_name="log",
|
|
14
|
+
name="level",
|
|
15
|
+
field=models.IntegerField(
|
|
16
|
+
blank=True,
|
|
17
|
+
choices=[
|
|
18
|
+
(50, "CRITICAL"),
|
|
19
|
+
(40, "ERROR"),
|
|
20
|
+
(30, "WARNING"),
|
|
21
|
+
(20, "INFO"),
|
|
22
|
+
(10, "DEBUG"),
|
|
23
|
+
(0, "NOTSET"),
|
|
24
|
+
],
|
|
25
|
+
null=True,
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
]
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import hashlib
|
|
2
2
|
import http
|
|
3
|
-
import logging
|
|
4
3
|
import secrets
|
|
5
4
|
from typing import Self
|
|
6
5
|
|
|
7
6
|
import msgspec
|
|
8
7
|
import sqlparse
|
|
9
8
|
from django.conf import settings
|
|
9
|
+
from django.contrib.contenttypes.models import ContentType
|
|
10
10
|
from django.core.management import call_command
|
|
11
11
|
from django.core.validators import RegexValidator
|
|
12
12
|
from django.db import connections, models
|
|
@@ -160,7 +160,16 @@ class Node(EnvironmentModel):
|
|
|
160
160
|
]
|
|
161
161
|
|
|
162
162
|
def __str__(self):
|
|
163
|
-
return self.name
|
|
163
|
+
return f"{self.environment}@{self.name}"
|
|
164
|
+
|
|
165
|
+
def get_absolute_url(self):
|
|
166
|
+
return reverse(
|
|
167
|
+
"node-details",
|
|
168
|
+
kwargs={
|
|
169
|
+
"slug": self.site.slug,
|
|
170
|
+
"pk": self.pk,
|
|
171
|
+
},
|
|
172
|
+
)
|
|
164
173
|
|
|
165
174
|
@classmethod
|
|
166
175
|
def update(cls, info: events.NodeInfo, site: Site, environment: str = ""):
|
|
@@ -256,6 +265,13 @@ class EventModel(EnvironmentModel):
|
|
|
256
265
|
event_id = models.UUIDField(db_index=True)
|
|
257
266
|
timestamp = models.DateTimeField(default=timezone.now)
|
|
258
267
|
tags = models.JSONField(default=dict, blank=True)
|
|
268
|
+
node = models.ForeignKey(
|
|
269
|
+
Node,
|
|
270
|
+
on_delete=models.SET_NULL,
|
|
271
|
+
related_name="%(class)ss",
|
|
272
|
+
null=True,
|
|
273
|
+
blank=True,
|
|
274
|
+
)
|
|
259
275
|
|
|
260
276
|
class Meta:
|
|
261
277
|
abstract = True
|
|
@@ -265,6 +281,18 @@ class EventModel(EnvironmentModel):
|
|
|
265
281
|
def from_event(cls, event: events.Event, **extra):
|
|
266
282
|
raise NotImplementedError()
|
|
267
283
|
|
|
284
|
+
def get_absolute_url(self):
|
|
285
|
+
ct = ContentType.objects.get_for_model(self)
|
|
286
|
+
return reverse(
|
|
287
|
+
"event-details",
|
|
288
|
+
kwargs={
|
|
289
|
+
"slug": self.site.slug,
|
|
290
|
+
"event_id": self.event_id,
|
|
291
|
+
"model": ct.model,
|
|
292
|
+
"pk": self.pk,
|
|
293
|
+
},
|
|
294
|
+
)
|
|
295
|
+
|
|
268
296
|
|
|
269
297
|
class FingerprintModel(models.Model):
|
|
270
298
|
fingerprint = models.CharField(max_length=64, db_index=True)
|
|
@@ -430,9 +458,17 @@ class Error(ContextualModel, FingerprintModel):
|
|
|
430
458
|
|
|
431
459
|
|
|
432
460
|
class Log(ContextualModel, FingerprintModel):
|
|
461
|
+
class Level(models.IntegerChoices):
|
|
462
|
+
CRITICAL = 50, "CRITICAL"
|
|
463
|
+
ERROR = 40, "ERROR"
|
|
464
|
+
WARNING = 30, "WARNING"
|
|
465
|
+
INFO = 20, "INFO"
|
|
466
|
+
DEBUG = 10, "DEBUG"
|
|
467
|
+
NOTSET = 0, "NOTSET"
|
|
468
|
+
|
|
433
469
|
message = models.TextField()
|
|
434
470
|
name = models.CharField(max_length=200, blank=True)
|
|
435
|
-
level = models.IntegerField(null=True, blank=True)
|
|
471
|
+
level = models.IntegerField(null=True, blank=True, choices=Level)
|
|
436
472
|
file = models.TextField(blank=True)
|
|
437
473
|
lineno = models.IntegerField(null=True, blank=True)
|
|
438
474
|
error = models.ForeignKey(
|
|
@@ -461,10 +497,6 @@ class Log(ContextualModel, FingerprintModel):
|
|
|
461
497
|
**extra,
|
|
462
498
|
)
|
|
463
499
|
|
|
464
|
-
@property
|
|
465
|
-
def level_name(self):
|
|
466
|
-
return logging.getLevelName(self.level) if self.level is not None else "UNKNOWN"
|
|
467
|
-
|
|
468
500
|
def fingerprint_parts(self):
|
|
469
501
|
return (self.message, self.name, self.level, self.file, self.lineno)
|
|
470
502
|
|
|
@@ -529,6 +561,10 @@ class Query(ContextualModel, FingerprintModel):
|
|
|
529
561
|
return stripped
|
|
530
562
|
return stripped[:79] + "…"
|
|
531
563
|
|
|
564
|
+
@property
|
|
565
|
+
def sql_formatted(self):
|
|
566
|
+
return sqlparse.format(self.sql, reindent=True, keyword_case="upper")
|
|
567
|
+
|
|
532
568
|
def fingerprint_parts(self):
|
|
533
569
|
return self.sql.split()
|
|
534
570
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from typing import Callable, ClassVar, Literal, Mapping, Self, TypeAlias
|
|
2
|
+
|
|
3
|
+
from django.db.models import QuerySet
|
|
4
|
+
from django.http import HttpRequest
|
|
5
|
+
from django.template import loader
|
|
6
|
+
from django.utils.datastructures import MultiValueDict
|
|
7
|
+
from django.utils.functional import Promise
|
|
8
|
+
|
|
9
|
+
StringValues: TypeAlias = dict[str, list[str]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def string_data(data) -> StringValues:
|
|
13
|
+
if isinstance(data, MultiValueDict):
|
|
14
|
+
return {str(key): [str(v) for v in values] for key, values in data.lists()}
|
|
15
|
+
elif isinstance(data, Mapping):
|
|
16
|
+
return {
|
|
17
|
+
str(key): (
|
|
18
|
+
[str(v) for v in values]
|
|
19
|
+
if isinstance(values, (list, tuple))
|
|
20
|
+
else ([] if values is None else [str(values)])
|
|
21
|
+
)
|
|
22
|
+
for key, values in data.items()
|
|
23
|
+
}
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SearchField:
|
|
28
|
+
template_name: str
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
label=None,
|
|
33
|
+
field_name=None,
|
|
34
|
+
empty_label="NULL",
|
|
35
|
+
# These belong on Facet
|
|
36
|
+
choice_label: Callable[..., str | Promise] | None = None,
|
|
37
|
+
order: Literal["value", "count"] = "value",
|
|
38
|
+
):
|
|
39
|
+
self.name = ""
|
|
40
|
+
self.label = label or ""
|
|
41
|
+
self.field_name = field_name or ""
|
|
42
|
+
self.prefix = ""
|
|
43
|
+
self.id = ""
|
|
44
|
+
self.empty_label = empty_label
|
|
45
|
+
self.choice_label = choice_label
|
|
46
|
+
self.order = order
|
|
47
|
+
|
|
48
|
+
def __set_name__(self, owner, name: str):
|
|
49
|
+
# print("__set_name__", owner, name)
|
|
50
|
+
assert issubclass(owner, Search)
|
|
51
|
+
self.name = name
|
|
52
|
+
self.prefix = f"{name}_"
|
|
53
|
+
self.id = f"id_{name}"
|
|
54
|
+
if not self.label:
|
|
55
|
+
self.label = name.capitalize().replace("__", " ")
|
|
56
|
+
if not self.field_name:
|
|
57
|
+
self.field_name = name
|
|
58
|
+
if not hasattr(owner, "_fields"):
|
|
59
|
+
owner._fields = []
|
|
60
|
+
owner._fields.append(self)
|
|
61
|
+
|
|
62
|
+
def __get__(self, instance: "Search", owner=None) -> StringValues | Self:
|
|
63
|
+
# print("__get__", instance, owner)
|
|
64
|
+
if owner is None:
|
|
65
|
+
return self
|
|
66
|
+
return {
|
|
67
|
+
key[len(self.prefix) :]: values
|
|
68
|
+
for key, values in instance._data.items()
|
|
69
|
+
if key.startswith(self.prefix)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def __set__(self, instance, value):
|
|
73
|
+
print("__set__", instance, value)
|
|
74
|
+
|
|
75
|
+
def apply(self, queryset: QuerySet, field_data: StringValues) -> QuerySet:
|
|
76
|
+
raise NotImplementedError()
|
|
77
|
+
|
|
78
|
+
def get_context(self, queryset: QuerySet, field_data: StringValues) -> dict:
|
|
79
|
+
return {
|
|
80
|
+
"field": self,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def render(
|
|
84
|
+
self,
|
|
85
|
+
queryset: QuerySet,
|
|
86
|
+
field_data: StringValues,
|
|
87
|
+
request: HttpRequest | None = None,
|
|
88
|
+
) -> str:
|
|
89
|
+
return loader.render_to_string(
|
|
90
|
+
self.template_name,
|
|
91
|
+
self.get_context(queryset, field_data),
|
|
92
|
+
request,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Search:
|
|
97
|
+
_fields: ClassVar[list[SearchField]]
|
|
98
|
+
|
|
99
|
+
def __init__(self, queryset: QuerySet, data=None):
|
|
100
|
+
self._queryset = queryset
|
|
101
|
+
self._data = string_data(data)
|
|
102
|
+
|
|
103
|
+
def queryset(self, for_field=None):
|
|
104
|
+
qs = self._queryset
|
|
105
|
+
for field in self._fields:
|
|
106
|
+
if field == for_field:
|
|
107
|
+
continue
|
|
108
|
+
field_data = getattr(self, field.name)
|
|
109
|
+
qs = field.apply(qs, field_data)
|
|
110
|
+
return qs
|
|
111
|
+
|
|
112
|
+
def render(self, request: HttpRequest | None = None) -> str:
|
|
113
|
+
fragments = []
|
|
114
|
+
for field in self._fields:
|
|
115
|
+
qs = self.queryset(for_field=field)
|
|
116
|
+
field_data = getattr(self, field.name)
|
|
117
|
+
fragments.append(field.render(qs, field_data, request))
|
|
118
|
+
return "".join(fragments)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from django.db.models import QuerySet
|
|
4
|
+
from django.utils.dateparse import parse_date
|
|
5
|
+
|
|
6
|
+
from .base import SearchField, StringValues
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def date_value(field_data: StringValues, name: str) -> datetime.date | None:
|
|
10
|
+
if name not in field_data:
|
|
11
|
+
return None
|
|
12
|
+
if not field_data[name]:
|
|
13
|
+
return None
|
|
14
|
+
return parse_date(field_data[name][0])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DateRange(SearchField):
|
|
18
|
+
template_name = "search/daterange.html"
|
|
19
|
+
|
|
20
|
+
def apply(self, queryset: QuerySet, field_data: StringValues) -> QuerySet:
|
|
21
|
+
filters = {}
|
|
22
|
+
if start := date_value(field_data, "start"):
|
|
23
|
+
filters[f"{self.field_name}__date__gte"] = start
|
|
24
|
+
if end := date_value(field_data, "end"):
|
|
25
|
+
filters[f"{self.field_name}__date__lte"] = end
|
|
26
|
+
return queryset.filter(**filters)
|
|
27
|
+
|
|
28
|
+
def get_context(self, queryset: QuerySet, field_data: StringValues) -> dict:
|
|
29
|
+
return {
|
|
30
|
+
**super().get_context(queryset, field_data),
|
|
31
|
+
"start": date_value(field_data, "start"),
|
|
32
|
+
"end": date_value(field_data, "end"),
|
|
33
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import operator
|
|
3
|
+
from typing import Any, ClassVar
|
|
4
|
+
|
|
5
|
+
from django.db.models import Count, Q, QuerySet
|
|
6
|
+
|
|
7
|
+
from .base import SearchField, StringValues
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Choice:
|
|
11
|
+
id: str
|
|
12
|
+
value: str
|
|
13
|
+
label: str
|
|
14
|
+
count: int
|
|
15
|
+
|
|
16
|
+
def __init__(self, field: SearchField, value: Any, count: int):
|
|
17
|
+
self.value = self.stringify(value)
|
|
18
|
+
if callable(field.choice_label):
|
|
19
|
+
self.label = field.choice_label(value)
|
|
20
|
+
else:
|
|
21
|
+
self.label = self.value or field.empty_label
|
|
22
|
+
self.id = field.id + "_" + self.value.replace(" ", "")
|
|
23
|
+
self.count = count
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def stringify(cls, value):
|
|
27
|
+
return str(value) if value is not None else ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Facet(SearchField):
|
|
31
|
+
choice_class: ClassVar[type[Choice]] = Choice
|
|
32
|
+
|
|
33
|
+
def get_choices(self, queryset):
|
|
34
|
+
choice_qs = queryset.values_list(self.field_name).annotate(num=Count("*"))
|
|
35
|
+
match self.order:
|
|
36
|
+
case "value":
|
|
37
|
+
choice_qs = choice_qs.order_by(self.field_name)
|
|
38
|
+
case "count":
|
|
39
|
+
choice_qs = choice_qs.order_by("-num")
|
|
40
|
+
return [self.choice_class(self, c, num) for c, num in choice_qs]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MultiFacet(Facet):
|
|
44
|
+
template_name = "search/multifacet.html"
|
|
45
|
+
|
|
46
|
+
def apply(self, queryset: QuerySet, field_data: StringValues) -> QuerySet:
|
|
47
|
+
filters = []
|
|
48
|
+
if selected := set(field_data.get("s", [])):
|
|
49
|
+
if "" in selected:
|
|
50
|
+
selected.discard("")
|
|
51
|
+
filters.append(Q(**{f"{self.field_name}__isnull": True}))
|
|
52
|
+
if selected:
|
|
53
|
+
filters.append(Q(**{f"{self.field_name}__in": selected}))
|
|
54
|
+
if filters:
|
|
55
|
+
queryset = queryset.filter(functools.reduce(operator.or_, filters))
|
|
56
|
+
return queryset
|
|
57
|
+
|
|
58
|
+
def get_context(self, queryset: QuerySet, field_data: StringValues) -> dict:
|
|
59
|
+
return {
|
|
60
|
+
**super().get_context(queryset, field_data),
|
|
61
|
+
"choices": self.get_choices(queryset),
|
|
62
|
+
"selected": field_data.get("s", []),
|
|
63
|
+
}
|
|
@@ -48,9 +48,9 @@ INSTALLED_APPS = [
|
|
|
48
48
|
"django.contrib.contenttypes",
|
|
49
49
|
"django.contrib.sessions",
|
|
50
50
|
"django.contrib.messages",
|
|
51
|
-
|
|
52
|
-
"varanus.server.apps.VaranusServer",
|
|
51
|
+
"whitenoise.runserver_nostatic",
|
|
53
52
|
"django.contrib.staticfiles",
|
|
53
|
+
"varanus.server.apps.VaranusServer",
|
|
54
54
|
"cconf",
|
|
55
55
|
"passkeys",
|
|
56
56
|
]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
{% load i18n %}
|
|
3
3
|
|
|
4
4
|
<!doctype html>
|
|
5
|
-
<html lang="en" data-bs-theme="
|
|
5
|
+
<html lang="en" data-bs-theme="dark">
|
|
6
6
|
<head>
|
|
7
7
|
<meta charset="utf-8" />
|
|
8
8
|
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
|
@@ -68,6 +68,8 @@
|
|
|
68
68
|
</footer>
|
|
69
69
|
|
|
70
70
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.bundle.min.js"></script>
|
|
71
|
+
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
|
|
72
|
+
|
|
71
73
|
<script src="{% static 'js/varanus.js' %}"></script>
|
|
72
74
|
|
|
73
75
|
{% block javascript %}{% endblock javascript %}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<div class="card mb-3">
|
|
2
|
+
<div class="card-header">{{ field.label }}</div>
|
|
3
|
+
<div class="card-body">
|
|
4
|
+
<div class="input-group">
|
|
5
|
+
<input type="date" class="form-control" placeholder="From" name="{{ field.prefix }}start" value="{{ start.isoformat|default:'' }}" />
|
|
6
|
+
<span class="input-group-text">-</span>
|
|
7
|
+
<input type="date" class="form-control" placeholder="To" name="{{ field.prefix }}end" value="{{ end.isoformat|default:'' }}" />
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<div class="card mb-3">
|
|
2
|
+
<div class="card-header">{{ field.label }}</div>
|
|
3
|
+
<div class="card-body">
|
|
4
|
+
{% for c in choices %}
|
|
5
|
+
<div class="form-check">
|
|
6
|
+
<input type="checkbox" id="{{ c.id }}" class="form-check-input" name="{{ field.prefix }}s" value="{{ c.value }}"{% if c.value in selected %} checked{% endif %} />
|
|
7
|
+
<label for="{{ c.id }}">{{ c.label }} ({{ c.count }})</label>
|
|
8
|
+
</div>
|
|
9
|
+
{% endfor %}
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
@@ -25,21 +25,24 @@
|
|
|
25
25
|
</li>
|
|
26
26
|
</ul>
|
|
27
27
|
|
|
28
|
-
<!--
|
|
29
28
|
<div class="container-fluid">
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
{%
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
<div class="row">
|
|
30
|
+
<div class="col-md-3">
|
|
31
|
+
{% block search %}
|
|
32
|
+
<form action="" method="get">
|
|
33
|
+
{{ search.render|safe }}
|
|
34
|
+
<button class="btn btn-primary">Filter</button>
|
|
35
|
+
<a href="{{ request.path }}" class="btn btn-secondary">Clear</a>
|
|
36
|
+
</form>
|
|
37
|
+
{% endblock search %}
|
|
38
|
+
</div>
|
|
39
|
+
<div class="col-md-9">
|
|
40
|
+
{% block page %}{% endblock page %}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
38
43
|
</div>
|
|
39
|
-
-->
|
|
40
44
|
|
|
41
|
-
<div class="
|
|
42
|
-
{% block page %}{% endblock page %}
|
|
45
|
+
<div class="offcanvas offcanvas-end" style="width:33%" tabindex="-1" id="inspector">
|
|
43
46
|
</div>
|
|
44
47
|
{% endblock content %}
|
|
45
48
|
|