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.
Files changed (74) hide show
  1. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/PKG-INFO +2 -2
  2. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/pyproject.toml +2 -2
  3. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/transport/http.py +13 -3
  4. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/events.py +10 -0
  5. varanus-0.1.0.dev2/src/varanus/server/asgi.py +7 -0
  6. varanus-0.1.0.dev2/src/varanus/server/migrations/0002_context_node_error_node_log_node_metric_node_and_more.py +79 -0
  7. varanus-0.1.0.dev2/src/varanus/server/migrations/0003_alter_log_level.py +28 -0
  8. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/models.py +43 -7
  9. varanus-0.1.0.dev2/src/varanus/server/search/__init__.py +9 -0
  10. varanus-0.1.0.dev2/src/varanus/server/search/base.py +118 -0
  11. varanus-0.1.0.dev2/src/varanus/server/search/date.py +33 -0
  12. varanus-0.1.0.dev2/src/varanus/server/search/facet.py +63 -0
  13. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/settings.py +2 -2
  14. varanus-0.1.0.dev2/src/varanus/server/static/css/varanus.css +4 -0
  15. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/base.html +3 -1
  16. varanus-0.1.0.dev2/src/varanus/server/templates/search/daterange.html +10 -0
  17. varanus-0.1.0.dev2/src/varanus/server/templates/search/multifacet.html +11 -0
  18. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/base.html +15 -12
  19. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/environment_nodes.html +6 -0
  20. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/error.html +49 -0
  21. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/log.html +36 -0
  22. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/metric.html +40 -0
  23. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/node.html +44 -0
  24. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/node_environments.html +6 -0
  25. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/query.html +31 -0
  26. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/request.html +136 -0
  27. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/errors.html +6 -4
  28. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/logs.html +7 -5
  29. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/metrics.html +6 -4
  30. varanus-0.1.0.dev2/src/varanus/server/templates/site/overview.html +56 -0
  31. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/queries.html +8 -4
  32. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/requests.html +6 -4
  33. varanus-0.1.0.dev2/src/varanus/server/templatetags/varanus.py +13 -0
  34. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/urls.py +27 -0
  35. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/views/api.py +7 -0
  36. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/views/base.py +1 -1
  37. varanus-0.1.0.dev2/src/varanus/server/views/site.py +165 -0
  38. varanus-0.1.0.dev0/src/varanus/server/asgi.py +0 -18
  39. varanus-0.1.0.dev0/src/varanus/server/management/commands/runserver.py +0 -15
  40. varanus-0.1.0.dev0/src/varanus/server/search.py +0 -120
  41. varanus-0.1.0.dev0/src/varanus/server/templates/site/overview.html +0 -14
  42. varanus-0.1.0.dev0/src/varanus/server/views/site.py +0 -69
  43. varanus-0.1.0.dev0/src/varanus/server/websockets.py +0 -13
  44. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/README.md +0 -0
  45. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/__init__.py +0 -0
  46. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/__init__.py +0 -0
  47. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/apps.py +0 -0
  48. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/client.py +0 -0
  49. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/context.py +0 -0
  50. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/loggers.py +0 -0
  51. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/middleware.py +0 -0
  52. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/transport/__init__.py +0 -0
  53. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/transport/base.py +0 -0
  54. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/client/transport/database.py +0 -0
  55. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/__init__.py +0 -0
  56. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/__main__.py +0 -0
  57. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/admin.py +0 -0
  58. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/apps.py +0 -0
  59. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/context_processors.py +0 -0
  60. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/management/__init__.py +0 -0
  61. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/management/commands/__init__.py +0 -0
  62. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/management/commands/migrateall.py +0 -0
  63. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/middleware.py +0 -0
  64. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/migrations/0001_initial.py +0 -0
  65. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/migrations/__init__.py +0 -0
  66. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/router.py +0 -0
  67. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/static/js/varanus.js +0 -0
  68. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/dashboard.html +0 -0
  69. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/templates/registration/login.html +0 -0
  70. {varanus-0.1.0.dev0/src/varanus/server/views → varanus-0.1.0.dev2/src/varanus/server/templatetags}/__init__.py +0 -0
  71. /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
  72. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/views/dashboard.py +0 -0
  73. {varanus-0.1.0.dev0 → varanus-0.1.0.dev2}/src/varanus/server/wsgi.py +0 -0
  74. {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.dev0
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.dev0"
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.client.post(self.ping_url, content=msgspec.json.encode(info))
34
+ self.request(self.ping_url, info)
25
35
 
26
36
  def send(self, event: events.Context):
27
- self.client.post(self.event_url, content=msgspec.json.encode(event))
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,7 @@
1
+ import os
2
+
3
+ from django.core.asgi import get_asgi_application
4
+
5
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "varanus.server.settings")
6
+
7
+ application = get_asgi_application()
@@ -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,9 @@
1
+ from .base import Search
2
+ from .date import DateRange
3
+ from .facet import MultiFacet
4
+
5
+ __all__ = [
6
+ "Search",
7
+ "DateRange",
8
+ "MultiFacet",
9
+ ]
@@ -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
- # Put this before staticfiles, so we use our runserver command.
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
  ]
@@ -0,0 +1,4 @@
1
+ .table-header th {
2
+ background-color: var(--bs-secondary-bg-subtle) !important;
3
+ border-color: var(--bs-secondary-border-subtle) !important;
4
+ }
@@ -2,7 +2,7 @@
2
2
  {% load i18n %}
3
3
 
4
4
  <!doctype html>
5
- <html lang="en" data-bs-theme="light">
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
- <form action="" method="GET">
31
- {% for search_field, form_fields in filter_form.fieldsets.items %}
32
- {% for field in form_fields %}
33
- {{ field }}
34
- {% endfor %}
35
- {% endfor %}
36
- <button>{% trans "Filter" %}</button>
37
- </form>
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="container-fluid">
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
 
@@ -0,0 +1,6 @@
1
+ <div class="offcanvas-header">
2
+ <h5 class="offcanvas-title">{{ environment }}</h5>
3
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
4
+ </div>
5
+ <div class="offcanvas-body">
6
+ </div>