plain.observer 0.4.0__py3-none-any.whl → 0.6.0__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 plain.observer might be problematic. Click here for more details.

plain/observer/otel.py CHANGED
@@ -10,6 +10,7 @@ from opentelemetry.semconv.attributes import url_attributes
10
10
  from opentelemetry.trace import SpanKind, format_span_id, format_trace_id
11
11
 
12
12
  from plain.http.cookie import unsign_cookie_value
13
+ from plain.logs import app_logger
13
14
  from plain.models.otel import suppress_db_tracing
14
15
  from plain.runtime import settings
15
16
 
@@ -218,15 +219,12 @@ class ObserverSpanProcessor(SpanProcessor):
218
219
  trace_info = self._traces[trace_id]
219
220
  trace_info["mode"] = mode
220
221
 
221
- # Clean up old traces if too many
222
- if len(self._traces) > 1000:
223
- # Remove oldest 100 traces
224
- oldest_ids = sorted(self._traces.keys())[:100]
225
- for old_id in oldest_ids:
226
- del self._traces[old_id]
227
-
228
222
  span_id = f"0x{format_span_id(span.get_span_context().span_id)}"
229
223
 
224
+ # Enable DEBUG logging only for PERSIST mode (when logs are captured)
225
+ if trace_info["mode"] == ObserverMode.PERSIST.value:
226
+ app_logger.debug_mode.start()
227
+
230
228
  # Store span (we know mode is truthy if we get here)
231
229
  trace_info["active_otel_spans"][span_id] = span
232
230
 
@@ -246,6 +244,10 @@ class ObserverSpanProcessor(SpanProcessor):
246
244
 
247
245
  trace_info = self._traces[trace_id]
248
246
 
247
+ # Disable DEBUG logging only for PERSIST mode spans
248
+ if trace_info["mode"] == ObserverMode.PERSIST.value:
249
+ app_logger.debug_mode.end()
250
+
249
251
  # Move span from active to completed
250
252
  if trace_info["active_otel_spans"].pop(span_id, None):
251
253
  trace_info["completed_otel_spans"].append(span)
@@ -264,16 +266,29 @@ class ObserverSpanProcessor(SpanProcessor):
264
266
 
265
267
  # Export if in persist mode
266
268
  if trace_info["mode"] == ObserverMode.PERSIST.value:
269
+ # Get and remove logs for this trace
270
+ from .logging import observer_log_handler
271
+
272
+ if observer_log_handler:
273
+ logs = observer_log_handler.pop_logs_for_trace(trace_id)
274
+ else:
275
+ logs = []
276
+
267
277
  logger.debug(
268
- "Exporting %d spans for trace %s",
278
+ "Exporting %d spans and %d logs for trace %s",
269
279
  len(trace_info["span_models"]),
280
+ len(logs),
270
281
  trace_id,
271
282
  )
272
283
  # The trace is done now, so we can get a more accurate summary
273
284
  trace_info["trace"].summary = trace_info["trace"].get_trace_summary(
274
285
  trace_info["span_models"]
275
286
  )
276
- self._export_trace(trace_info["trace"], trace_info["span_models"])
287
+ self._export_trace(
288
+ trace=trace_info["trace"],
289
+ spans=trace_info["span_models"],
290
+ logs=logs,
291
+ )
277
292
 
278
293
  # Clean up trace
279
294
  del self._traces[trace_id]
@@ -315,19 +330,38 @@ class ObserverSpanProcessor(SpanProcessor):
315
330
 
316
331
  return trace_info["trace"].get_trace_summary(span_models)
317
332
 
318
- def _export_trace(self, trace, span_models):
319
- """Export trace and spans to the database."""
320
- from .models import Span, Trace
333
+ def _export_trace(self, *, trace, spans, logs):
334
+ """Export trace, spans, and logs to the database."""
335
+ from .models import Log, Span, Trace
321
336
 
322
337
  with suppress_db_tracing():
323
338
  try:
324
339
  trace.save()
325
340
 
326
- for span_model in span_models:
327
- span_model.trace = trace
341
+ for span in spans:
342
+ span.trace = trace
328
343
 
329
344
  # Bulk create spans
330
- Span.objects.bulk_create(span_models)
345
+ Span.objects.bulk_create(spans)
346
+
347
+ # Create log models if we have logs
348
+ if logs:
349
+ # Create a mapping of span_id to span_model
350
+ span_id_to_model = {
351
+ span_model.span_id: span_model for span_model in spans
352
+ }
353
+
354
+ log_models = []
355
+ for log_entry in logs:
356
+ log_model = Log.from_log_record(
357
+ record=log_entry["record"],
358
+ trace=trace,
359
+ span=span_id_to_model.get(log_entry["span_id"]),
360
+ )
361
+ log_models.append(log_model)
362
+
363
+ Log.objects.bulk_create(log_models)
364
+
331
365
  except Exception as e:
332
366
  logger.warning(
333
367
  "Failed to export trace to database: %s",
@@ -339,8 +373,11 @@ class ObserverSpanProcessor(SpanProcessor):
339
373
  if settings.OBSERVER_TRACE_LIMIT > 0:
340
374
  try:
341
375
  if Trace.objects.count() > settings.OBSERVER_TRACE_LIMIT:
376
+ excess_count = (
377
+ Trace.objects.count() - settings.OBSERVER_TRACE_LIMIT
378
+ )
342
379
  delete_ids = Trace.objects.order_by("start_time")[
343
- : settings.OBSERVER_TRACE_LIMIT
380
+ :excess_count
344
381
  ].values_list("id", flat=True)
345
382
  Trace.objects.filter(id__in=delete_ids).delete()
346
383
  except Exception as e:
@@ -0,0 +1,19 @@
1
+ <div style="padding-left: {{ event.span_level * 1 }}rem;" class="border-l border-white/10">
2
+ <div class="ml-px px-2 py-1 text-xs flex items-start space-x-2">
3
+ <div class="w-4 h-4 mr-2 flex items-center justify-center">
4
+ <svg class="w-3 h-3 transform transition-transform" fill="currentColor" viewBox="0 0 20 20">
5
+ <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"/>
6
+ </svg>
7
+ </div>
8
+ <span class="text-white/40 whitespace-nowrap tabular-nums" title="{{ log.timestamp|localtime }}">{{ log.timestamp|localtime|strftime("%-I:%M:%S %p") }}</span>
9
+ <span class="font-mono px-1 py-0 text-xs font-medium rounded
10
+ data-[level='DEBUG']:text-stone-400
11
+ data-[level='INFO']:text-blue-400
12
+ data-[level='WARNING']:text-yellow-400
13
+ data-[level='ERROR']:text-red-400
14
+ data-[level='CRITICAL']:text-red-300
15
+ text-stone-400 text-center flex-shrink-0"
16
+ data-level="{{ log.level }}">{{ log.level }}</span>
17
+ <span class="font-mono text-white/90 break-words">{{ log.message }}</span>
18
+ </div>
19
+ </div>
@@ -0,0 +1,299 @@
1
+ {% set span_start_offset = ((span.start_time - trace.start_time).total_seconds() * 1000) %}
2
+ {% set start_percent = (span_start_offset / trace.duration_ms() * 100) if trace.duration_ms() > 0 else 0 %}
3
+ {% set width_percent = (span.duration_ms() / trace.duration_ms() * 100) if trace.duration_ms() > 0 else 0 %}
4
+
5
+ <div style="padding-left: {{ event.span_level * 1 }}rem;" class="border-l border-white/20">
6
+ <details class="rounded bg-white/5 min-w-[600px] ml-px">
7
+ <summary class="cursor-pointer hover:bg-white/10 transition-colors px-2 py-1 list-none [&::-webkit-details-marker]:hidden">
8
+ <div class="flex items-center">
9
+ <div class="w-4 h-4 mr-2 flex items-center justify-center">
10
+ <svg class="w-3 h-3 transform transition-transform details-open:rotate-90" fill="currentColor" viewBox="0 0 20 20">
11
+ <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
12
+ </svg>
13
+ </div>
14
+
15
+ <div class="w-80 flex items-center space-x-2">
16
+ <div class="text-white/40 whitespace-nowrap tabular-nums" title="{{ span.start_time|localtime }}">
17
+ {{ span.start_time|localtime|strftime("%-I:%M:%S %p") }}
18
+ </div>
19
+ <div class="flex-grow whitespace-nowrap truncate text-white/90">{{ span.name }}</div>
20
+
21
+ {% if span.annotations %}
22
+ <div class="flex items-center space-x-1 flex-shrink-0">
23
+ {% for annotation in span.annotations %}
24
+ <span class="w-4 h-4 inline-flex justify-center items-center text-xs rounded-full
25
+ data-[severity='warning']:bg-amber-500/20
26
+ data-[severity='warning']:text-amber-400
27
+ data-[severity='error']:bg-red-500/20
28
+ data-[severity='error']:text-red-400
29
+ data-[severity='info']:bg-blue-500/20
30
+ data-[severity='info']:text-blue-400"
31
+ data-severity="{{ annotation.severity }}"
32
+ title="{{ annotation.message }}">
33
+ !
34
+ </span>
35
+ {% endfor %}
36
+ </div>
37
+ {% endif %}
38
+ </div>
39
+
40
+ <div class="flex-1 px-4 min-w-[300px]">
41
+ <div class="relative h-6 bg-white/2 rounded-sm">
42
+ <div
43
+ class="absolute top-1 bottom-1 rounded-sm transition-opacity hover:opacity-80
44
+ data-[kind='SERVER']:bg-blue-500
45
+ data-[kind='CLIENT']:bg-emerald-500
46
+ data-[kind='CONSUMER']:bg-amber-500
47
+ data-[kind='PRODUCER']:bg-purple-500
48
+ data-[kind='INTERNAL']:bg-gray-500
49
+ bg-white/30"
50
+ data-kind="{{ span.kind }}"
51
+ style="left: {{ start_percent }}%; width: {{ width_percent }}%;"
52
+ title="{{ span.name }} - {{ span.duration_ms() }}ms">
53
+ </div>
54
+ <div
55
+ class="absolute inset-0 flex items-center justify-start pl-1 text-xs text-white/80 font-medium whitespace-nowrap pointer-events-none"
56
+ style="left: {{ start_percent }}%; width: {{ width_percent }}%;">
57
+ {{ "%.2f"|format(span.duration_ms()) }}ms
58
+ </div>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ </summary>
63
+ <div class="p-4 pt-2 bg-white/3 border-t border-white/20">
64
+ {% if span.sql_query %}
65
+ <div class="mb-6 bg-white/3 rounded-lg border border-white/20 overflow-hidden
66
+ {% if span.annotations %}ring-2 ring-amber-500/50{% endif %}">
67
+ <div class="bg-white/10 px-4 py-2 border-b border-white/20 flex items-center justify-between">
68
+ <h4 class="text-sm font-semibold text-emerald-400 flex items-center">
69
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-database-fill w-4 h-4 mr-2" viewBox="0 0 16 16">
70
+ <path d="M3.904 1.777C4.978 1.289 6.427 1 8 1s3.022.289 4.096.777C13.125 2.245 14 2.993 14 4s-.875 1.755-1.904 2.223C11.022 6.711 9.573 7 8 7s-3.022-.289-4.096-.777C2.875 5.755 2 5.007 2 4s.875-1.755 1.904-2.223"/>
71
+ <path d="M2 6.161V7c0 1.007.875 1.755 1.904 2.223C4.978 9.71 6.427 10 8 10s3.022-.289 4.096-.777C13.125 8.755 14 8.007 14 7v-.839c-.457.432-1.004.751-1.49.972C11.278 7.693 9.682 8 8 8s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972"/>
72
+ <path d="M2 9.161V10c0 1.007.875 1.755 1.904 2.223C4.978 12.711 6.427 13 8 13s3.022-.289 4.096-.777C13.125 11.755 14 11.007 14 10v-.839c-.457.432-1.004.751-1.49.972-1.232.56-2.828.867-4.51.867s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972"/>
73
+ <path d="M2 12.161V13c0 1.007.875 1.755 1.904 2.223C4.978 15.711 6.427 16 8 16s3.022-.289 4.096-.777C13.125 14.755 14 14.007 14 13v-.839c-.457.432-1.004.751-1.49.972-1.232.56-2.828.867-4.51.867s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972"/>
74
+ </svg>
75
+ Database Query
76
+ </h4>
77
+ {% if span.annotations %}
78
+ <div class="flex items-center space-x-1">
79
+ {% for annotation in span.annotations %}
80
+ <span class="px-2 py-0.5 text-xs rounded-full
81
+ data-[severity='warning']:bg-amber-500/20
82
+ data-[severity='warning']:text-amber-400
83
+ data-[severity='error']:bg-red-500/20
84
+ data-[severity='error']:text-red-400
85
+ data-[severity='info']:bg-blue-500/20
86
+ data-[severity='info']:text-blue-400"
87
+ data-severity="{{ annotation.severity }}">
88
+ {{ annotation.message }}
89
+ </span>
90
+ {% endfor %}
91
+ </div>
92
+ {% endif %}
93
+ </div>
94
+ <div class="p-4">
95
+ <pre class="text-xs text-white/80 font-mono whitespace-pre-wrap overflow-x-auto"><code>{{ span.get_formatted_sql() }}</code></pre>
96
+
97
+ {% if span.sql_query_params %}
98
+ <div class="mt-4 pt-4 border-t border-white/20">
99
+ <h5 class="text-xs font-semibold text-white/40 mb-2">Query Parameters</h5>
100
+ <div class="space-y-1">
101
+ {% for param_key, param_value in span.sql_query_params.items() %}
102
+ <div class="flex text-xs">
103
+ <span class="text-white/50 min-w-0 flex-shrink-0 pr-2 font-mono">{{ param_key }}:</span>
104
+ <span class="text-white/70 break-words font-mono">{{ param_value }}</span>
105
+ </div>
106
+ {% endfor %}
107
+ </div>
108
+ </div>
109
+ {% endif %}
110
+
111
+ </div>
112
+ </div>
113
+ {% endif %}
114
+
115
+ {% if span.source_code_location %}
116
+ <div class="mb-6 bg-white/3 rounded-lg border border-white/20 overflow-hidden">
117
+ <div class="bg-white/10 px-4 py-2 border-b border-white/20 flex items-center justify-between">
118
+ <h4 class="text-sm font-semibold text-blue-400 flex items-center">
119
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-code-slash w-4 h-4 mr-2" viewBox="0 0 16 16">
120
+ <path d="M10.478 1.647a.5.5 0 1 0-.956-.294l-4 13a.5.5 0 0 0 .956.294zM4.854 4.146a.5.5 0 0 1 0 .708L1.707 8l3.147 3.146a.5.5 0 0 1-.708.708l-3.5-3.5a.5.5 0 0 1 0-.708l3.5-3.5a.5.5 0 0 1 .708 0m6.292 0a.5.5 0 0 0 0 .708L14.293 8l-3.147 3.146a.5.5 0 0 0 .708.708l3.5-3.5a.5.5 0 0 0 0-.708l-3.5-3.5a.5.5 0 0 0-.708 0"/>
121
+ </svg>
122
+ Source Code Location
123
+ </h4>
124
+ </div>
125
+ <div class="p-4">
126
+ <div class="space-y-2 text-xs">
127
+ {% for key, value in span.source_code_location.items() %}
128
+ {% if key == "Stacktrace" %}
129
+ <div class="mt-3 pt-3 border-t border-white/10">
130
+ <details class="cursor-pointer">
131
+ <summary class="text-white/50 text-xs font-mono mb-2 hover:text-white/70 transition-colors">{{ key }}</summary>
132
+ <div class="bg-white/5 rounded p-3 max-h-64 overflow-y-auto">
133
+ <pre class="text-white/80 font-mono text-xs whitespace-pre-wrap break-words"><code>{{ value }}</code></pre>
134
+ </div>
135
+ </details>
136
+ </div>
137
+ {% else %}
138
+ <div class="flex">
139
+ <span class="text-white/50 min-w-0 flex-shrink-0 pr-2 font-mono w-20">{{ key }}</span>
140
+ <span class="text-white/70 break-words font-mono">{{ value }}</span>
141
+ </div>
142
+ {% endif %}
143
+ {% endfor %}
144
+ </div>
145
+ </div>
146
+ </div>
147
+ {% endif %}
148
+
149
+ {% if span.get_exception_stacktrace() %}
150
+ <div class="mb-6 bg-red-900/20 rounded-lg border border-red-600/30 overflow-hidden">
151
+ <div class="bg-red-900/40 px-4 py-2 border-b border-red-600/30">
152
+ <h4 class="text-sm font-semibold text-red-300 flex items-center">
153
+ <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
154
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
155
+ </svg>
156
+ Exception Stacktrace
157
+ </h4>
158
+ </div>
159
+ <div class="p-4">
160
+ <pre class="text-xs text-red-100 font-mono whitespace-pre-wrap overflow-x-auto"><code>{{ span.get_exception_stacktrace() }}</code></pre>
161
+ </div>
162
+ </div>
163
+ {% endif %}
164
+
165
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
166
+ <div>
167
+ <h4 class="text-sm font-semibold text-white/70 mb-3">Basic Information</h4>
168
+ <div class="space-y-2 text-xs">
169
+ <div class="flex">
170
+ <span class="text-white/40 w-20">ID:</span>
171
+ <span class="text-white/80 font-mono">{{ span.span_id }}</span>
172
+ </div>
173
+ <div class="flex">
174
+ <span class="text-white/40 w-20">Name:</span>
175
+ <span class="text-white/80">{{ span.name }}</span>
176
+ </div>
177
+ <div class="flex">
178
+ <span class="text-white/40 w-20">Kind:</span>
179
+ <span class="px-2 py-0.5 rounded text-xs font-medium
180
+ data-[kind='SERVER']:bg-blue-500/20 data-[kind='SERVER']:text-blue-300
181
+ data-[kind='CLIENT']:bg-emerald-500/20 data-[kind='CLIENT']:text-emerald-300
182
+ data-[kind='CONSUMER']:bg-amber-500/20 data-[kind='CONSUMER']:text-amber-300
183
+ data-[kind='PRODUCER']:bg-purple-500/20 data-[kind='PRODUCER']:text-purple-300
184
+ data-[kind='INTERNAL']:bg-gray-500/20 data-[kind='INTERNAL']:text-gray-300
185
+ bg-gray-500/20 text-gray-300"
186
+ data-kind="{{ span.kind }}">
187
+ {{ span.kind }}
188
+ </span>
189
+ </div>
190
+ <div class="flex">
191
+ <span class="text-white/40 w-20">Duration:</span>
192
+ <span class="text-white/80">{{ "%.2f"|format(span.duration_ms() or 0) }}ms</span>
193
+ </div>
194
+ {% if span.parent_id %}
195
+ <div class="flex">
196
+ <span class="text-white/40 w-20">Parent:</span>
197
+ <span class="text-white/80 font-mono text-xs">{{ span.parent_id }}</span>
198
+ </div>
199
+ {% endif %}
200
+ </div>
201
+ </div>
202
+
203
+ <div>
204
+ <h4 class="text-sm font-semibold text-white/70 mb-3">Timing</h4>
205
+ <div class="space-y-2 text-xs">
206
+ <div class="flex">
207
+ <span class="text-white/40 w-20">Started:</span>
208
+ <span class="text-white/80">{{ span.start_time|localtime|strftime("%-I:%M:%S.%f %p") }}</span>
209
+ </div>
210
+ <div class="flex">
211
+ <span class="text-white/40 w-20">Ended:</span>
212
+ <span class="text-white/80">{{ span.end_time|localtime|strftime("%-I:%M:%S.%f %p") }}</span>
213
+ </div>
214
+ {% if span.status and span.status != '' and span.status != 'UNSET' %}
215
+ <div class="flex">
216
+ <span class="text-white/40 w-20">Status:</span>
217
+ <span class="px-2 py-0.5 rounded text-xs font-medium
218
+ data-[status='ERROR']:bg-red-500/20 data-[status='ERROR']:text-red-300
219
+ data-[status='OK']:bg-green-500/20 data-[status='OK']:text-green-300
220
+ bg-yellow-500/20 text-yellow-300"
221
+ data-status="{{ span.status }}">
222
+ {{ span.status }}
223
+ </span>
224
+ </div>
225
+ {% endif %}
226
+ </div>
227
+ </div>
228
+ </div>
229
+
230
+ {% if span.attributes %}
231
+ <div class="mt-6">
232
+ <h4 class="text-sm font-semibold text-white/70 mb-3">Attributes</h4>
233
+ <div class="bg-white/3 rounded p-3 max-h-48 overflow-y-auto">
234
+ <div class="space-y-1 text-xs">
235
+ {% for key, value in span.attributes.items() %}
236
+ <div class="flex">
237
+ <span class="text-white/40 min-w-0 flex-shrink-0 pr-2">{{ key }}:</span>
238
+ <span class="text-white/80 break-words">{{ value }}</span>
239
+ </div>
240
+ {% endfor %}
241
+ </div>
242
+ </div>
243
+ </div>
244
+ {% endif %}
245
+
246
+ {% if span.events %}
247
+ <div class="mt-6">
248
+ <h4 class="text-sm font-semibold text-white/70 mb-3">Events ({{ span.events|length }})</h4>
249
+ <div class="bg-white/3 rounded p-3 max-h-48 overflow-y-auto">
250
+ <div class="space-y-3 text-xs">
251
+ {% for event in span.events %}
252
+ <div class="border-l-2 border-white/20 pl-3">
253
+ <div class="flex items-center justify-between mb-1">
254
+ <div class="text-white/80 font-medium">{{ event.name }}</div>
255
+ <div class="text-white/40 text-xs">
256
+ {% set formatted_time = span.format_event_timestamp(event.timestamp) %}
257
+ {% if formatted_time.__class__.__name__ == 'datetime' %}
258
+ {{ formatted_time|localtime|strftime("%-I:%M:%S.%f %p") }}
259
+ {% else %}
260
+ {{ formatted_time }}
261
+ {% endif %}
262
+ </div>
263
+ </div>
264
+ {% if event.attributes %}
265
+ <div class="space-y-1">
266
+ {% for key, value in event.attributes.items() %}
267
+ <div class="flex">
268
+ <span class="text-white/40 min-w-0 flex-shrink-0 pr-2">{{ key }}:</span>
269
+ <pre class="text-white/80 whitespace-pre-wrap break-words font-mono text-xs">{{ value }}</pre>
270
+ </div>
271
+ {% endfor %}
272
+ </div>
273
+ {% endif %}
274
+ </div>
275
+ {% endfor %}
276
+ </div>
277
+ </div>
278
+ </div>
279
+ {% endif %}
280
+
281
+ {% if span.links %}
282
+ <div class="mt-6">
283
+ <h4 class="text-sm font-semibold text-white/70 mb-3">Links ({{ span.links|length }})</h4>
284
+ <div class="bg-stone-800/50 rounded p-3">
285
+ <div class="space-y-2 text-xs">
286
+ {% for link in span.links %}
287
+ <div class="border-l-2 border-white/20 pl-2">
288
+ <div class="text-white/80 font-mono">{{ link.context.trace_id }}</div>
289
+ <div class="text-white/40 font-mono">{{ link.context.span_id }}</div>
290
+ </div>
291
+ {% endfor %}
292
+ </div>
293
+ </div>
294
+ </div>
295
+ {% endif %}
296
+
297
+ </div>
298
+ </details>
299
+ </div>