varanus 0.1.0.dev3__tar.gz → 0.1.0.dev4__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 (89) hide show
  1. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/PKG-INFO +1 -1
  2. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/pyproject.toml +1 -1
  3. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/client/client.py +72 -22
  4. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/client/loggers.py +4 -2
  5. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/client/middleware.py +9 -13
  6. varanus-0.1.0.dev4/src/varanus/client/transport/database.py +55 -0
  7. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/events.py +33 -6
  8. {varanus-0.1.0.dev3/src/varanus/server → varanus-0.1.0.dev4/src/varanus}/search/__init__.py +3 -2
  9. varanus-0.1.0.dev4/src/varanus/search/base.py +159 -0
  10. varanus-0.1.0.dev4/src/varanus/search/fields.py +189 -0
  11. varanus-0.1.0.dev4/src/varanus/search/templates/search/daterange.html +11 -0
  12. varanus-0.1.0.dev4/src/varanus/search/templates/search/filter.html +1 -0
  13. varanus-0.1.0.dev4/src/varanus/search/templates/search/hidden.html +12 -0
  14. varanus-0.1.0.dev4/src/varanus/search/templates/search/multifacet.html +6 -0
  15. varanus-0.1.0.dev4/src/varanus/search/templates/search/search.html +23 -0
  16. varanus-0.1.0.dev4/src/varanus/search/utils.py +38 -0
  17. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/admin.py +40 -14
  18. varanus-0.1.0.dev4/src/varanus/server/context_processors.py +9 -0
  19. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/migrations/0001_initial.py +157 -60
  20. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/models.py +87 -16
  21. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/router.py +3 -0
  22. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/settings.py +32 -0
  23. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templates/base.html +2 -2
  24. varanus-0.1.0.dev4/src/varanus/server/templates/dashboard.html +11 -0
  25. varanus-0.1.0.dev4/src/varanus/server/templates/site/base.html +65 -0
  26. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templates/site/details/error.html +1 -1
  27. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templates/site/details/metric.html +4 -4
  28. varanus-0.1.0.dev4/src/varanus/server/templates/site/details/node_env.html +24 -0
  29. varanus-0.1.0.dev3/src/varanus/server/templates/site/details/node.html → varanus-0.1.0.dev4/src/varanus/server/templates/site/details/node_packages.html +1 -1
  30. varanus-0.1.0.dev4/src/varanus/server/templates/site/details/node_settings.html +24 -0
  31. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templates/site/details/query.html +17 -0
  32. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templates/site/details/request.html +27 -17
  33. varanus-0.1.0.dev4/src/varanus/server/templates/site/errors.html +44 -0
  34. varanus-0.1.0.dev4/src/varanus/server/templates/site/logs.html +47 -0
  35. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templates/site/metrics.html +17 -5
  36. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templates/site/overview.html +10 -6
  37. varanus-0.1.0.dev4/src/varanus/server/templates/site/queries.html +64 -0
  38. varanus-0.1.0.dev4/src/varanus/server/templates/site/requests.html +54 -0
  39. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templatetags/varanus.py +7 -0
  40. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/urls.py +19 -2
  41. varanus-0.1.0.dev4/src/varanus/server/utils.py +8 -0
  42. varanus-0.1.0.dev4/src/varanus/server/views/dashboard.py +30 -0
  43. varanus-0.1.0.dev4/src/varanus/server/views/site.py +254 -0
  44. varanus-0.1.0.dev3/src/varanus/client/transport/database.py +0 -31
  45. varanus-0.1.0.dev3/src/varanus/server/context_processors.py +0 -7
  46. varanus-0.1.0.dev3/src/varanus/server/migrations/0002_context_node_error_node_log_node_metric_node_and_more.py +0 -79
  47. varanus-0.1.0.dev3/src/varanus/server/migrations/0003_alter_log_level.py +0 -28
  48. varanus-0.1.0.dev3/src/varanus/server/search/base.py +0 -116
  49. varanus-0.1.0.dev3/src/varanus/server/search/date.py +0 -33
  50. varanus-0.1.0.dev3/src/varanus/server/search/facet.py +0 -63
  51. varanus-0.1.0.dev3/src/varanus/server/templates/dashboard.html +0 -4
  52. varanus-0.1.0.dev3/src/varanus/server/templates/search/daterange.html +0 -10
  53. varanus-0.1.0.dev3/src/varanus/server/templates/search/multifacet.html +0 -11
  54. varanus-0.1.0.dev3/src/varanus/server/templates/site/base.html +0 -57
  55. varanus-0.1.0.dev3/src/varanus/server/templates/site/errors.html +0 -30
  56. varanus-0.1.0.dev3/src/varanus/server/templates/site/logs.html +0 -32
  57. varanus-0.1.0.dev3/src/varanus/server/templates/site/queries.html +0 -36
  58. varanus-0.1.0.dev3/src/varanus/server/templates/site/requests.html +0 -32
  59. varanus-0.1.0.dev3/src/varanus/server/views/dashboard.py +0 -11
  60. varanus-0.1.0.dev3/src/varanus/server/views/site.py +0 -165
  61. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/README.md +0 -0
  62. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/__init__.py +0 -0
  63. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/client/__init__.py +0 -0
  64. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/client/apps.py +0 -0
  65. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/client/context.py +0 -0
  66. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/client/transport/__init__.py +0 -0
  67. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/client/transport/base.py +0 -0
  68. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/client/transport/http.py +0 -0
  69. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/__init__.py +0 -0
  70. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/__main__.py +0 -0
  71. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/apps.py +0 -0
  72. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/asgi.py +0 -0
  73. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/management/__init__.py +0 -0
  74. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/management/commands/__init__.py +0 -0
  75. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/management/commands/migrateall.py +0 -0
  76. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/middleware.py +0 -0
  77. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/migrations/__init__.py +0 -0
  78. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/static/css/varanus.css +0 -0
  79. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/static/js/varanus.js +0 -0
  80. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templates/registration/login.html +0 -0
  81. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templates/site/details/environment_nodes.html +0 -0
  82. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templates/site/details/log.html +0 -0
  83. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templates/site/details/node_environments.html +0 -0
  84. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/templatetags/__init__.py +0 -0
  85. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/views/__init__.py +0 -0
  86. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/views/api.py +0 -0
  87. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/views/base.py +0 -0
  88. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/server/wsgi.py +0 -0
  89. {varanus-0.1.0.dev3 → varanus-0.1.0.dev4}/src/varanus/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: varanus
3
- Version: 0.1.0.dev3
3
+ Version: 0.1.0.dev4
4
4
  Summary: Django application monitoring.
5
5
  Requires-Dist: httpx>=0.27.0
6
6
  Requires-Dist: msgspec>=0.19.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "varanus"
3
- version = "0.1.0.dev3"
3
+ version = "0.1.0.dev4"
4
4
  description = "Django application monitoring."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,5 +1,7 @@
1
+ import os
1
2
  import platform
2
3
  from importlib.metadata import distributions
4
+ from typing import Iterable
3
5
  from urllib.parse import urlsplit
4
6
 
5
7
  from varanus.events import Context, NodeInfo
@@ -18,23 +20,29 @@ def install_query_logger(logger):
18
20
  return handler
19
21
 
20
22
 
23
+ def resolve_include_exclude(
24
+ items: Iterable[str],
25
+ include: Iterable[str] | bool,
26
+ exclude: Iterable[str] | None,
27
+ ) -> set[str]:
28
+ if not include:
29
+ return set()
30
+
31
+ if include is True:
32
+ resolved = set(items)
33
+ else:
34
+ resolved = set(items).intersection(include)
35
+
36
+ if exclude is not None:
37
+ resolved.difference_update(exclude)
38
+
39
+ return resolved
40
+
41
+
21
42
  class VaranusClient:
22
- environment: str
23
- transport: BaseTransport
24
-
25
- request_attr: str
26
- logger_name: str
27
- tags: dict
28
-
29
- include_headers: list | bool | None
30
- exclude_headers: list | None
31
- sensitive_headers = set(
32
- [
33
- "authorization",
34
- "cookie",
35
- "proxy-authorization",
36
- ]
37
- )
43
+ sensitive_headers = set(["authorization", "cookie", "proxy-authorization"])
44
+ sensitive_settings = set(["DATABASES"])
45
+ sensitive_env = set(["PGPASSWORD"])
38
46
 
39
47
  scheme_transports = {
40
48
  "http": "varanus.client.transport.http.ThreadedHttpTransport",
@@ -42,22 +50,27 @@ class VaranusClient:
42
50
  "db": "varanus.client.transport.database.ModelTransport",
43
51
  }
44
52
 
45
- send_all: bool
46
53
  configured = False
47
54
 
48
55
  def setup(
49
56
  self,
50
57
  dsn: str,
58
+ environment: str,
51
59
  node: str | None = None,
52
- environment: str = "",
53
60
  transport_class: str | type[BaseTransport] | None = None,
54
61
  request_attr: str = "varanus",
55
62
  logger_name: str = "varanus.request",
56
63
  tags: dict | None = None,
57
- include_headers: list | bool | None = None,
58
- exclude_headers: list | None = None,
64
+ include_headers: Iterable[str] | bool = False,
65
+ exclude_headers: Iterable[str] | None = None,
66
+ include_settings: Iterable[str] | bool = False,
67
+ exclude_settings: Iterable[str] | None = None,
68
+ include_default_settings: bool = False,
69
+ include_env: Iterable[str] | bool = False,
70
+ exclude_env: Iterable[str] | None = None,
59
71
  log_queries: bool | int = False,
60
72
  log_query_params: bool = False,
73
+ log_query_stack: bool = False,
61
74
  query_metrics: bool | str = False,
62
75
  send_all: bool = False,
63
76
  install: list | None = None,
@@ -83,7 +96,20 @@ class VaranusClient:
83
96
  self.logger_name = logger_name
84
97
  self.tags = tags or {}
85
98
  self.include_headers = include_headers
86
- self.exclude_headers = exclude_headers
99
+ self.exclude_headers = (
100
+ self.sensitive_headers if exclude_headers is None else set(exclude_headers)
101
+ )
102
+ self.include_settings = include_settings
103
+ self.exclude_settings = (
104
+ self.sensitive_settings
105
+ if exclude_settings is None
106
+ else set(exclude_settings)
107
+ )
108
+ self.include_default_settings = include_default_settings
109
+ self.include_env = include_env
110
+ self.exclude_env = (
111
+ self.sensitive_env if exclude_env is None else set(exclude_env)
112
+ )
87
113
  if log_queries or query_metrics:
88
114
  try:
89
115
  # The logger is installed as early as possible, and for all connections.
@@ -93,6 +119,7 @@ class VaranusClient:
93
119
  self.query_logger = QueryLogger(
94
120
  log_queries,
95
121
  log_query_params,
122
+ log_query_stack,
96
123
  query_metrics,
97
124
  )
98
125
  # Install it in each new connection (if it's not already installed).
@@ -122,12 +149,35 @@ class VaranusClient:
122
149
  self.transport.send(e)
123
150
 
124
151
  def ping(self):
152
+ from django import get_version
153
+ from django.conf import settings
154
+
155
+ include_settings = resolve_include_exclude(
156
+ [s for s in dir(settings) if s == s.upper()],
157
+ self.include_settings,
158
+ self.exclude_settings,
159
+ )
160
+ include_env = resolve_include_exclude(
161
+ os.environ.keys(),
162
+ self.include_env,
163
+ self.exclude_env,
164
+ )
165
+
125
166
  self.transport.ping(
126
167
  NodeInfo(
127
168
  name=self.node,
128
169
  platform=platform.platform(),
129
- python_version=platform.python_version(),
170
+ language=platform.python_implementation(),
171
+ language_version=platform.python_version(),
172
+ framework="Django",
173
+ framework_version=get_version(),
130
174
  packages={d.name: d.version for d in distributions()},
175
+ settings={
176
+ s: repr(getattr(settings, s))
177
+ for s in include_settings
178
+ if self.include_default_settings or settings.is_overridden(s)
179
+ },
180
+ environment={e: os.environ[e] for e in include_env},
131
181
  )
132
182
  )
133
183
 
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  import sys
3
3
 
4
- from ..events import Log, Query, now
4
+ from ..events import Log, Query, capture_stack, now
5
5
  from .context import ONE_MS, current_context
6
6
 
7
7
 
@@ -12,7 +12,7 @@ class VaranusHandler(logging.Handler):
12
12
 
13
13
 
14
14
  class QueryLogger:
15
- def __init__(self, threshold, log_params, metrics):
15
+ def __init__(self, threshold, log_params, log_stack, metrics):
16
16
  # TODO: add callback for tagging?
17
17
  if threshold is True:
18
18
  self.threshold = 0
@@ -21,6 +21,7 @@ class QueryLogger:
21
21
  else:
22
22
  self.threshold = int(threshold)
23
23
  self.log_params = log_params
24
+ self.log_stack = log_stack
24
25
  if isinstance(metrics, str):
25
26
  self.metrics_name = metrics
26
27
  else:
@@ -55,5 +56,6 @@ class QueryLogger:
55
56
  db=context["connection"].alias,
56
57
  elapsed_ms=elapsed_ms,
57
58
  success=success,
59
+ stack=capture_stack(1) if self.log_stack else [],
58
60
  )
59
61
  )
@@ -4,7 +4,7 @@ from django.core.exceptions import MiddlewareNotUsed
4
4
  from django.http import HttpRequest, HttpResponse
5
5
 
6
6
  from ..events import Request
7
- from .client import client
7
+ from .client import client, resolve_include_exclude
8
8
 
9
9
 
10
10
  def get_ip(request: HttpRequest):
@@ -22,17 +22,12 @@ def get_ip(request: HttpRequest):
22
22
 
23
23
  def request_headers(request: HttpRequest):
24
24
  headers = {}
25
- if not client.include_headers:
26
- return headers
27
- if client.include_headers is True:
28
- include = set(name.lower() for name in request.headers)
29
- else:
30
- include = set(name.lower() for name in client.include_headers)
31
- if client.exclude_headers is None:
32
- exclude = client.sensitive_headers
33
- else:
34
- exclude = set(name.lower() for name in client.exclude_headers)
35
- for name in sorted(include - exclude):
25
+ include_headers = resolve_include_exclude(
26
+ [name.lower() for name in request.headers],
27
+ client.include_headers,
28
+ client.exclude_headers,
29
+ )
30
+ for name in include_headers:
36
31
  value = request.headers.get(name)
37
32
  if value is not None:
38
33
  headers[name] = value
@@ -59,8 +54,9 @@ class VaranusMiddleware:
59
54
  # TODO: any need for request tags separate from context tags?
60
55
  varanus.request = Request(
61
56
  host=request.get_host(),
62
- path=request.path,
63
57
  method=request.method or "",
58
+ path=request.path,
59
+ query=request.META.get("QUERY_STRING", ""),
64
60
  status=response.status_code,
65
61
  headers=request_headers(request),
66
62
  size=(
@@ -0,0 +1,55 @@
1
+ import typing
2
+ import uuid
3
+ from urllib.parse import SplitResult
4
+
5
+ from varanus import events
6
+
7
+ from .base import BaseTransport
8
+
9
+ if typing.TYPE_CHECKING:
10
+ from varanus.server.models import Node, Site
11
+
12
+
13
+ class ModelTransport(BaseTransport):
14
+ site: "Site"
15
+ node: "Node"
16
+
17
+ def __init__(self, url: SplitResult, environment: str, node: str):
18
+ self.slug = url.netloc
19
+ self.environment = environment
20
+
21
+ def ensure_site(self):
22
+ if hasattr(self, "site"):
23
+ return
24
+
25
+ from varanus.server.models import Site
26
+
27
+ self.site, created = Site.objects.get_or_create(
28
+ slug=self.slug,
29
+ defaults={
30
+ "name": self.slug,
31
+ "schema_name": self.slug,
32
+ },
33
+ )
34
+
35
+ def ping(self, info: events.NodeInfo):
36
+ from varanus.server.models import Node
37
+
38
+ self.ensure_site()
39
+
40
+ with self.site.activated():
41
+ self.node, created, updates = Node.update(
42
+ info, site=self.site, environment=self.environment
43
+ )
44
+
45
+ def send(self, event: events.Context):
46
+ from varanus.server.models import Context
47
+
48
+ with self.site.activated():
49
+ Context.from_event(
50
+ event,
51
+ event_id=uuid.uuid4(),
52
+ site=self.site,
53
+ environment=self.environment,
54
+ node=self.node,
55
+ )
@@ -17,11 +17,16 @@ class Event(Struct, kw_only=True, omit_defaults=True):
17
17
  class NodeInfo(Struct):
18
18
  name: str
19
19
  platform: str
20
- python_version: str
20
+ language: str
21
+ language_version: str
22
+ framework: str
23
+ framework_version: str
21
24
  packages: dict[str, str]
25
+ settings: dict[str, str]
26
+ environment: dict[str, str]
22
27
 
23
28
 
24
- class ErrorLine(Struct):
29
+ class StackLine(Struct):
25
30
  file: str | None
26
31
  lineno: int | None
27
32
  function: str | None
@@ -30,11 +35,31 @@ class ErrorLine(Struct):
30
35
  locals: dict[str, str]
31
36
 
32
37
 
38
+ def capture_stack(skip: int = 0, include_locals: bool = False) -> list[StackLine]:
39
+ lines = []
40
+ for frame in inspect.stack()[skip + 1 :]:
41
+ lines.append(
42
+ StackLine(
43
+ file=frame.filename,
44
+ lineno=frame.lineno,
45
+ linesrc=frame.code_context[0].strip() if frame.code_context else "",
46
+ function=frame.function,
47
+ module=frame.frame.f_globals.get("__name__", ""),
48
+ locals=(
49
+ {name: repr(val) for name, val in frame.frame.f_locals.items()}
50
+ if include_locals
51
+ else {}
52
+ ),
53
+ )
54
+ )
55
+ return lines
56
+
57
+
33
58
  class Error(Event):
34
59
  kind: str
35
60
  module: str
36
61
  message: str
37
- lines: list[ErrorLine] = []
62
+ stack: list[StackLine] = []
38
63
 
39
64
  @classmethod
40
65
  def from_exception(cls, exc_info, tags=None):
@@ -63,7 +88,7 @@ class Error(Event):
63
88
  pass
64
89
  module = f_globals.get("__name__", "")
65
90
  lines.append(
66
- ErrorLine(
91
+ StackLine(
67
92
  file=abs_path,
68
93
  lineno=lineno,
69
94
  function=function,
@@ -78,7 +103,7 @@ class Error(Event):
78
103
  kind=kind,
79
104
  module=module,
80
105
  message=message,
81
- lines=lines,
106
+ stack=lines,
82
107
  )
83
108
 
84
109
 
@@ -144,12 +169,14 @@ class Query(Event):
144
169
  db: str
145
170
  elapsed_ms: int
146
171
  success: bool
172
+ stack: list[StackLine] = []
147
173
 
148
174
 
149
175
  class Request(Event):
150
176
  host: str
151
- path: str
152
177
  method: str
178
+ path: str
179
+ query: str
153
180
  status: int
154
181
  headers: dict = {}
155
182
  size: int | None = None
@@ -1,9 +1,10 @@
1
1
  from .base import Search
2
- from .date import DateRange
3
- from .facet import MultiFacet
2
+ from .fields import DateRange, Filter, Hidden, MultiFacet
4
3
 
5
4
  __all__ = [
6
5
  "Search",
7
6
  "DateRange",
7
+ "Filter",
8
+ "Hidden",
8
9
  "MultiFacet",
9
10
  ]
@@ -0,0 +1,159 @@
1
+ from typing import Callable, ClassVar, Iterable, Literal, Self
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
+ from django.utils.safestring import SafeString
9
+
10
+ from .utils import Namer, StringValues, string_data
11
+
12
+
13
+ class SkipRender(Exception):
14
+ pass
15
+
16
+
17
+ class SearchField:
18
+ template_name: str
19
+ prefixed = False
20
+ default = False
21
+
22
+ def __init__(
23
+ self,
24
+ label=None,
25
+ field_name: str | Iterable[str] | None = None,
26
+ empty_label="NULL",
27
+ # These belong on Facet
28
+ choice_label: Callable[..., str | Promise] | None = None,
29
+ order: Literal["value", "count"] = "value",
30
+ default: bool = False,
31
+ ):
32
+ self.name = ""
33
+ self.label = label or ""
34
+ self.field_name = field_name or ""
35
+ self.prefix = ""
36
+ self.id = ""
37
+ self.empty_label = empty_label
38
+ self.choice_label = choice_label
39
+ self.order = order
40
+ self.default = default
41
+
42
+ def __set_name__(self, owner, name: str):
43
+ assert issubclass(owner, Search)
44
+ self.name = name
45
+ self.prefix = f"{name}_" if self.prefixed else ""
46
+ self.id = f"id_{name}"
47
+ if not self.label:
48
+ self.label = name.capitalize().replace("__", " ")
49
+ if not self.field_name:
50
+ self.field_name = name
51
+ if not hasattr(owner, "_fields"):
52
+ owner._fields = []
53
+ owner._fields.append(self)
54
+
55
+ def __get__(self, instance: "Search", owner=None) -> StringValues | Self:
56
+ if owner is None:
57
+ return self
58
+ if self.prefixed:
59
+ return {
60
+ key[len(self.prefix) :]: values
61
+ for key, values in instance._data.items()
62
+ if key.startswith(self.prefix)
63
+ }
64
+ else:
65
+ return (
66
+ {self.name: instance._data[self.name]}
67
+ if self.name in instance._data
68
+ else {}
69
+ )
70
+
71
+ def __set__(self, instance, value):
72
+ raise AttributeError(f"`{self.name}` is read-only")
73
+
74
+ @property
75
+ def named(self):
76
+ """
77
+ Allows for {{ field.named.something }} in templates.
78
+ """
79
+ return Namer(self.prefix)
80
+
81
+ def apply(
82
+ self,
83
+ queryset: QuerySet,
84
+ field_data: StringValues,
85
+ request: HttpRequest | None = None,
86
+ ) -> QuerySet:
87
+ raise NotImplementedError()
88
+
89
+ def has_value(
90
+ self,
91
+ queryset: QuerySet,
92
+ field_data: StringValues,
93
+ request: HttpRequest | None = None,
94
+ ) -> bool:
95
+ return any(any(v) for v in field_data.values())
96
+
97
+ def get_context(
98
+ self,
99
+ queryset: QuerySet,
100
+ field_data: StringValues,
101
+ request: HttpRequest | None = None,
102
+ ) -> dict:
103
+ return {
104
+ "field": self,
105
+ }
106
+
107
+ def render(
108
+ self,
109
+ queryset: QuerySet,
110
+ field_data: StringValues,
111
+ request: HttpRequest | None = None,
112
+ ) -> SafeString:
113
+ return loader.render_to_string(
114
+ self.template_name,
115
+ self.get_context(queryset, field_data, request=request),
116
+ request,
117
+ )
118
+
119
+
120
+ class Search:
121
+ _fields: ClassVar[list[SearchField]]
122
+ template_name = "search/search.html"
123
+
124
+ def __init__(
125
+ self,
126
+ queryset: QuerySet,
127
+ data: dict | MultiValueDict | None = None,
128
+ request: HttpRequest | None = None,
129
+ ):
130
+ self._queryset = queryset
131
+ self._data = string_data(data)
132
+ self._request = request
133
+
134
+ def queryset(self, for_field=None):
135
+ qs = self._queryset
136
+ for field in self._fields:
137
+ if field == for_field:
138
+ continue
139
+ field_data = getattr(self, field.name)
140
+ qs = field.apply(qs, field_data, request=self._request)
141
+ return qs
142
+
143
+ def render(self) -> SafeString:
144
+ context = {"fields": []}
145
+ initial = not bool(self._data)
146
+ for field in self._fields:
147
+ qs = self.queryset(for_field=field)
148
+ field_data = getattr(self, field.name)
149
+ try:
150
+ rendered = field.render(qs, field_data, request=self._request)
151
+ default = (initial and field.default) or field.has_value(
152
+ qs,
153
+ field_data,
154
+ request=self._request,
155
+ )
156
+ context["fields"].append((field, default, rendered))
157
+ except SkipRender:
158
+ continue
159
+ return loader.render_to_string(self.template_name, context, self._request)