pum 1.2.3__py3-none-any.whl → 1.3.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.
- pum/__init__.py +71 -10
- pum/changelog.py +61 -1
- pum/checker.py +444 -214
- pum/cli.py +279 -135
- pum/config_model.py +56 -33
- pum/connection.py +30 -0
- pum/dependency_handler.py +69 -4
- pum/dumper.py +14 -4
- pum/exceptions.py +9 -0
- pum/feedback.py +119 -0
- pum/hook.py +95 -29
- pum/info.py +0 -2
- pum/parameter.py +4 -0
- pum/pum_config.py +103 -20
- pum/report_generator.py +1043 -0
- pum/role_manager.py +151 -23
- pum/schema_migrations.py +173 -36
- pum/sql_content.py +83 -21
- pum/upgrader.py +287 -23
- {pum-1.2.3.dist-info → pum-1.3.0.dist-info}/METADATA +6 -2
- pum-1.3.0.dist-info/RECORD +25 -0
- {pum-1.2.3.dist-info → pum-1.3.0.dist-info}/WHEEL +1 -1
- pum-1.2.3.dist-info/RECORD +0 -22
- {pum-1.2.3.dist-info → pum-1.3.0.dist-info}/entry_points.txt +0 -0
- {pum-1.2.3.dist-info → pum-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {pum-1.2.3.dist-info → pum-1.3.0.dist-info}/top_level.txt +0 -0
pum/report_generator.py
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
"""Report generation for database comparison results."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from jinja2 import Template
|
|
7
|
+
|
|
8
|
+
JINJA2_AVAILABLE = True
|
|
9
|
+
except ImportError:
|
|
10
|
+
JINJA2_AVAILABLE = False
|
|
11
|
+
|
|
12
|
+
from .checker import ComparisonReport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ReportGenerator:
|
|
16
|
+
"""Generates reports from ComparisonReport objects."""
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def _format_difference(content: dict | str, check_name: str = "") -> dict:
|
|
20
|
+
"""Format difference for better readability.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
content: The structured dict or raw string from the difference
|
|
24
|
+
check_name: The name of the check (e.g., "Views", "Triggers", "Columns")
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
dict with formatting information
|
|
28
|
+
"""
|
|
29
|
+
# If already structured data (dict), format it appropriately
|
|
30
|
+
if isinstance(content, dict):
|
|
31
|
+
# Determine object type based on available keys
|
|
32
|
+
# IMPORTANT: Check order matters! Constraints, Indexes, and Columns all have 'column_name'
|
|
33
|
+
# Check most specific first
|
|
34
|
+
if check_name == "Constraints" or "constraint_name" in content:
|
|
35
|
+
# Constraint format
|
|
36
|
+
schema = content.get("constraint_schema", "") or "public"
|
|
37
|
+
table = content.get("table_name", "")
|
|
38
|
+
constraint_name = content.get("constraint_name", "")
|
|
39
|
+
constraint_type = content.get("constraint_type", "")
|
|
40
|
+
constraint_def = content.get("constraint_definition", "")
|
|
41
|
+
|
|
42
|
+
# Build details - only include non-empty values
|
|
43
|
+
details = []
|
|
44
|
+
if constraint_type:
|
|
45
|
+
details.append(f"Type: {constraint_type}")
|
|
46
|
+
if content.get("column_name") and content.get("column_name") != "":
|
|
47
|
+
details.append(f"Column: {content['column_name']}")
|
|
48
|
+
if content.get("foreign_table_name") and content.get("foreign_table_name") != "":
|
|
49
|
+
details.append(f"References: {content['foreign_table_name']}")
|
|
50
|
+
if content.get("foreign_column_name") and content.get("foreign_column_name") != "":
|
|
51
|
+
details.append(f"Foreign column: {content['foreign_column_name']}")
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
"is_structured": True,
|
|
55
|
+
"type": "constraint",
|
|
56
|
+
"schema_object": f"{schema}.{table}",
|
|
57
|
+
"detail": constraint_name,
|
|
58
|
+
"extra": " | ".join(details) if details else None,
|
|
59
|
+
"sql": constraint_def if constraint_def and constraint_def != "" else None,
|
|
60
|
+
}
|
|
61
|
+
elif check_name == "Indexes" or "index_name" in content:
|
|
62
|
+
# Index format - check BEFORE columns since indexes also have column_name
|
|
63
|
+
schema = content.get("schema_name", "") or "public"
|
|
64
|
+
table = content.get("table_name", "")
|
|
65
|
+
index_name = content.get("index_name", "")
|
|
66
|
+
column = content.get("column_name", "")
|
|
67
|
+
index_def = content.get("index_definition", "")
|
|
68
|
+
|
|
69
|
+
# Build details
|
|
70
|
+
details = []
|
|
71
|
+
if column and column != "":
|
|
72
|
+
details.append(f"Column: {column}")
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"is_structured": True,
|
|
76
|
+
"type": "index",
|
|
77
|
+
"schema_object": f"{schema}.{table}",
|
|
78
|
+
"detail": index_name,
|
|
79
|
+
"extra": " | ".join(details) if details else None,
|
|
80
|
+
"sql": index_def if index_def and index_def != "" else None,
|
|
81
|
+
}
|
|
82
|
+
elif check_name == "Columns" or "column_name" in content:
|
|
83
|
+
# Column format
|
|
84
|
+
schema = content.get("table_schema", "")
|
|
85
|
+
table = content.get("table_name", "")
|
|
86
|
+
|
|
87
|
+
# Build details
|
|
88
|
+
details = []
|
|
89
|
+
if content.get("data_type"):
|
|
90
|
+
details.append(f"Type: {content['data_type']}")
|
|
91
|
+
if content.get("is_nullable"):
|
|
92
|
+
details.append(f"Nullable: {content['is_nullable']}")
|
|
93
|
+
if (
|
|
94
|
+
content.get("column_default")
|
|
95
|
+
and str(content.get("column_default")).lower() != "none"
|
|
96
|
+
):
|
|
97
|
+
details.append(f"Default: {content['column_default']}")
|
|
98
|
+
if (
|
|
99
|
+
content.get("character_maximum_length")
|
|
100
|
+
and str(content.get("character_maximum_length")).lower() != "none"
|
|
101
|
+
):
|
|
102
|
+
details.append(f"Max length: {content['character_maximum_length']}")
|
|
103
|
+
if (
|
|
104
|
+
content.get("numeric_precision")
|
|
105
|
+
and str(content.get("numeric_precision")).lower() != "none"
|
|
106
|
+
):
|
|
107
|
+
details.append(f"Precision: {content['numeric_precision']}")
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"is_structured": True,
|
|
111
|
+
"type": "column",
|
|
112
|
+
"schema_object": f"{schema}.{table}",
|
|
113
|
+
"detail": content.get("column_name", ""),
|
|
114
|
+
"extra": " | ".join(details) if details else None,
|
|
115
|
+
"sql": None,
|
|
116
|
+
}
|
|
117
|
+
elif check_name == "Views" or "view_definition" in content:
|
|
118
|
+
# View format
|
|
119
|
+
schema = content.get("table_schema", "") or "public"
|
|
120
|
+
view_name = content.get("table_name", "")
|
|
121
|
+
# Handle both old and new column names
|
|
122
|
+
sql = content.get("view_definition") or content.get("replace", "")
|
|
123
|
+
return {
|
|
124
|
+
"is_structured": True,
|
|
125
|
+
"type": "view",
|
|
126
|
+
"schema_object": f"{schema}.{view_name}",
|
|
127
|
+
"detail": None,
|
|
128
|
+
"sql": sql,
|
|
129
|
+
}
|
|
130
|
+
elif check_name == "Triggers" or "prosrc" in content:
|
|
131
|
+
# Trigger format
|
|
132
|
+
schema = content.get("schema_name", "")
|
|
133
|
+
table = content.get("relname", "")
|
|
134
|
+
trigger = content.get("tgname", "")
|
|
135
|
+
sql = content.get("prosrc", "")
|
|
136
|
+
return {
|
|
137
|
+
"is_structured": True,
|
|
138
|
+
"type": "trigger",
|
|
139
|
+
"schema_object": f"{schema}.{table}",
|
|
140
|
+
"detail": trigger,
|
|
141
|
+
"sql": sql,
|
|
142
|
+
}
|
|
143
|
+
elif check_name == "Functions" or "routine_definition" in content:
|
|
144
|
+
# Function format - show SQL in scrollable widget
|
|
145
|
+
schema = content.get("routine_schema", "")
|
|
146
|
+
function_name = content.get("routine_name", "")
|
|
147
|
+
sql = content.get("routine_definition", "")
|
|
148
|
+
param_type = content.get("data_type", "")
|
|
149
|
+
|
|
150
|
+
# Only add parameter type if it's a real value
|
|
151
|
+
extra = None
|
|
152
|
+
if param_type and param_type not in ["", "None", None]:
|
|
153
|
+
extra = f"Parameter type: {param_type}"
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"is_structured": True,
|
|
157
|
+
"type": "function",
|
|
158
|
+
"schema_object": f"{schema}.{function_name}",
|
|
159
|
+
"detail": "",
|
|
160
|
+
"extra": extra,
|
|
161
|
+
"sql": sql if sql and sql != "" else None,
|
|
162
|
+
}
|
|
163
|
+
elif check_name == "Rules" or "rule_name" in content:
|
|
164
|
+
# Rule format
|
|
165
|
+
schema = content.get("rule_schema", "") or "public"
|
|
166
|
+
table = content.get("rule_table", "")
|
|
167
|
+
rule_name = content.get("rule_name", "")
|
|
168
|
+
event = content.get("rule_event", "")
|
|
169
|
+
|
|
170
|
+
# Build details
|
|
171
|
+
details = []
|
|
172
|
+
if event:
|
|
173
|
+
details.append(f"Event: {event}")
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
"is_structured": True,
|
|
177
|
+
"type": "rule",
|
|
178
|
+
"schema_object": f"{schema}.{table}",
|
|
179
|
+
"detail": rule_name,
|
|
180
|
+
"extra": " | ".join(details) if details else None,
|
|
181
|
+
"sql": None,
|
|
182
|
+
}
|
|
183
|
+
else:
|
|
184
|
+
# Generic object format (tables, sequences, etc.)
|
|
185
|
+
# Try to find schema and name keys
|
|
186
|
+
schema = (
|
|
187
|
+
content.get("table_schema")
|
|
188
|
+
or content.get("constraint_schema")
|
|
189
|
+
or content.get("schema_name", "")
|
|
190
|
+
or "public"
|
|
191
|
+
)
|
|
192
|
+
name = (
|
|
193
|
+
content.get("table_name")
|
|
194
|
+
or content.get("relname")
|
|
195
|
+
or content.get("routine_name", "")
|
|
196
|
+
)
|
|
197
|
+
if not name and len(content) >= 2:
|
|
198
|
+
# Fallback: use first two values as schema.name
|
|
199
|
+
values = list(content.values())
|
|
200
|
+
schema = str(values[0]) if values else "public"
|
|
201
|
+
name = str(values[1]) if len(values) > 1 else ""
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
"is_structured": True,
|
|
205
|
+
"type": "object",
|
|
206
|
+
"schema_object": f"{schema}.{name}" if schema and name else str(content),
|
|
207
|
+
"detail": None,
|
|
208
|
+
"extra": None,
|
|
209
|
+
"sql": None,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# Fallback: old string-based parsing (for backward compatibility)
|
|
213
|
+
return {"is_structured": False, "content": str(content), "sql": None}
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _group_columns_by_table(differences):
|
|
217
|
+
"""Group column differences by table.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
differences: List of DifferenceItem objects
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
dict: Grouped columns by table and type
|
|
224
|
+
"""
|
|
225
|
+
from collections import defaultdict
|
|
226
|
+
|
|
227
|
+
grouped = defaultdict(lambda: {"removed": [], "added": []})
|
|
228
|
+
|
|
229
|
+
for diff in differences:
|
|
230
|
+
if isinstance(diff.content, dict) and "column_name" in diff.content:
|
|
231
|
+
# Structured column data
|
|
232
|
+
table = (
|
|
233
|
+
f"{diff.content.get('table_schema', '')}.{diff.content.get('table_name', '')}"
|
|
234
|
+
)
|
|
235
|
+
diff_type = "removed" if diff.type.value == "removed" else "added"
|
|
236
|
+
|
|
237
|
+
# Format column details
|
|
238
|
+
details = []
|
|
239
|
+
if diff.content.get("data_type"):
|
|
240
|
+
details.append(f"Type: {diff.content['data_type']}")
|
|
241
|
+
if diff.content.get("is_nullable"):
|
|
242
|
+
details.append(f"Nullable: {diff.content['is_nullable']}")
|
|
243
|
+
if (
|
|
244
|
+
diff.content.get("column_default")
|
|
245
|
+
and str(diff.content["column_default"]).lower() != "none"
|
|
246
|
+
):
|
|
247
|
+
details.append(f"Default: {diff.content['column_default']}")
|
|
248
|
+
if (
|
|
249
|
+
diff.content.get("character_maximum_length")
|
|
250
|
+
and str(diff.content["character_maximum_length"]).lower() != "none"
|
|
251
|
+
):
|
|
252
|
+
details.append(f"Max length: {diff.content['character_maximum_length']}")
|
|
253
|
+
if (
|
|
254
|
+
diff.content.get("numeric_precision")
|
|
255
|
+
and str(diff.content["numeric_precision"]).lower() != "none"
|
|
256
|
+
):
|
|
257
|
+
details.append(f"Precision: {diff.content['numeric_precision']}")
|
|
258
|
+
|
|
259
|
+
grouped[table][diff_type].append(
|
|
260
|
+
{
|
|
261
|
+
"column": diff.content.get("column_name", ""),
|
|
262
|
+
"extra": " | ".join(details),
|
|
263
|
+
"diff": diff,
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
# Fallback for old string format
|
|
268
|
+
formatted = ReportGenerator._format_difference(diff.content, "Columns")
|
|
269
|
+
if formatted.get("is_structured") and formatted.get("type") == "column":
|
|
270
|
+
table = formatted["schema_object"]
|
|
271
|
+
diff_type = "removed" if diff.type.value == "removed" else "added"
|
|
272
|
+
grouped[table][diff_type].append(
|
|
273
|
+
{
|
|
274
|
+
"column": formatted["detail"],
|
|
275
|
+
"extra": formatted.get("extra", ""),
|
|
276
|
+
"diff": diff,
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return dict(grouped)
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def generate_text(report: ComparisonReport) -> str:
|
|
284
|
+
"""Generate a text report.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
report: The comparison report
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Text report as a string
|
|
291
|
+
|
|
292
|
+
"""
|
|
293
|
+
lines = []
|
|
294
|
+
for result in report.check_results:
|
|
295
|
+
lines.append(result.name)
|
|
296
|
+
for diff in result.differences:
|
|
297
|
+
lines.append(str(diff))
|
|
298
|
+
return "\n".join(lines)
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def generate_json(report: ComparisonReport) -> str:
|
|
302
|
+
"""Generate a JSON report.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
report: The comparison report
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
JSON report as a string
|
|
309
|
+
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
def serialize_diff(diff):
|
|
313
|
+
"""Serialize a DifferenceItem to a dict."""
|
|
314
|
+
return {
|
|
315
|
+
"type": diff.type.value,
|
|
316
|
+
"content": diff.content if isinstance(diff.content, dict) else str(diff.content),
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
def serialize_result(result):
|
|
320
|
+
"""Serialize a CheckResult to a dict."""
|
|
321
|
+
return {
|
|
322
|
+
"name": result.name,
|
|
323
|
+
"key": result.key,
|
|
324
|
+
"passed": result.passed,
|
|
325
|
+
"difference_count": result.difference_count,
|
|
326
|
+
"differences": [serialize_diff(diff) for diff in result.differences],
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
data = {
|
|
330
|
+
"pg_connection1": report.pg_connection1,
|
|
331
|
+
"pg_connection2": report.pg_connection2,
|
|
332
|
+
"timestamp": report.timestamp.isoformat(),
|
|
333
|
+
"passed": report.passed,
|
|
334
|
+
"total_checks": report.total_checks,
|
|
335
|
+
"passed_checks": report.passed_checks,
|
|
336
|
+
"failed_checks": report.failed_checks,
|
|
337
|
+
"total_differences": report.total_differences,
|
|
338
|
+
"check_results": [serialize_result(result) for result in report.check_results],
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return json.dumps(data, indent=2)
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def generate_html(report: ComparisonReport) -> str:
|
|
345
|
+
"""Generate an HTML report.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
report: The comparison report
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
HTML report as a string
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
ImportError: If Jinja2 is not installed
|
|
355
|
+
|
|
356
|
+
"""
|
|
357
|
+
if not JINJA2_AVAILABLE:
|
|
358
|
+
raise ImportError(
|
|
359
|
+
"Jinja2 is required for HTML report generation. "
|
|
360
|
+
"Install it with: pip install 'pum[html]'"
|
|
361
|
+
)
|
|
362
|
+
template_str = """<!DOCTYPE html>
|
|
363
|
+
<html lang="en">
|
|
364
|
+
<head>
|
|
365
|
+
<meta charset="UTF-8">
|
|
366
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
367
|
+
<title>Database Comparison Report</title>
|
|
368
|
+
<style>
|
|
369
|
+
* {
|
|
370
|
+
margin: 0;
|
|
371
|
+
padding: 0;
|
|
372
|
+
box-sizing: border-box;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
body {
|
|
376
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
377
|
+
line-height: 1.6;
|
|
378
|
+
color: #333;
|
|
379
|
+
background: #f5f5f5;
|
|
380
|
+
padding: 20px;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.container {
|
|
384
|
+
max-width: 1200px;
|
|
385
|
+
margin: 0 auto;
|
|
386
|
+
background: white;
|
|
387
|
+
border-radius: 8px;
|
|
388
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
389
|
+
overflow: hidden;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.header {
|
|
393
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
394
|
+
color: white;
|
|
395
|
+
padding: 30px;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.header h1 {
|
|
399
|
+
font-size: 28px;
|
|
400
|
+
margin-bottom: 10px;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.header .meta {
|
|
404
|
+
opacity: 0.9;
|
|
405
|
+
font-size: 14px;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.summary {
|
|
409
|
+
display: grid;
|
|
410
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
411
|
+
gap: 20px;
|
|
412
|
+
padding: 30px;
|
|
413
|
+
background: #f8f9fa;
|
|
414
|
+
border-bottom: 1px solid #e9ecef;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.summary-card {
|
|
418
|
+
background: white;
|
|
419
|
+
padding: 20px;
|
|
420
|
+
border-radius: 6px;
|
|
421
|
+
border-left: 4px solid #667eea;
|
|
422
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.summary-card.success {
|
|
426
|
+
border-left-color: #28a745;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.summary-card.error {
|
|
430
|
+
border-left-color: #dc3545;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.summary-card.warning {
|
|
434
|
+
border-left-color: #ffc107;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
.summary-card .value {
|
|
438
|
+
font-size: 32px;
|
|
439
|
+
font-weight: bold;
|
|
440
|
+
color: #667eea;
|
|
441
|
+
margin: 10px 0;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.summary-card.success .value {
|
|
445
|
+
color: #28a745;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.summary-card.error .value {
|
|
449
|
+
color: #dc3545;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.summary-card.warning .value {
|
|
453
|
+
color: #ffc107;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.summary-card .label {
|
|
457
|
+
font-size: 14px;
|
|
458
|
+
color: #6c757d;
|
|
459
|
+
text-transform: uppercase;
|
|
460
|
+
letter-spacing: 0.5px;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.content {
|
|
464
|
+
padding: 30px;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.check-section {
|
|
468
|
+
margin-bottom: 30px;
|
|
469
|
+
border: 1px solid #e9ecef;
|
|
470
|
+
border-radius: 6px;
|
|
471
|
+
overflow: hidden;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.check-header {
|
|
475
|
+
background: #f8f9fa;
|
|
476
|
+
padding: 15px 20px;
|
|
477
|
+
display: flex;
|
|
478
|
+
justify-content: space-between;
|
|
479
|
+
align-items: center;
|
|
480
|
+
cursor: pointer;
|
|
481
|
+
user-select: none;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.check-header:hover {
|
|
485
|
+
background: #e9ecef;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.check-header h2 {
|
|
489
|
+
font-size: 18px;
|
|
490
|
+
display: flex;
|
|
491
|
+
align-items: center;
|
|
492
|
+
gap: 10px;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.badge {
|
|
496
|
+
display: inline-block;
|
|
497
|
+
padding: 4px 12px;
|
|
498
|
+
border-radius: 12px;
|
|
499
|
+
font-size: 12px;
|
|
500
|
+
font-weight: 600;
|
|
501
|
+
text-transform: uppercase;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.badge.success {
|
|
505
|
+
background: #d4edda;
|
|
506
|
+
color: #155724;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.badge.error {
|
|
510
|
+
background: #f8d7da;
|
|
511
|
+
color: #721c24;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.diff-count {
|
|
515
|
+
font-size: 14px;
|
|
516
|
+
color: #6c757d;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.check-body {
|
|
520
|
+
padding: 20px;
|
|
521
|
+
background: white;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.check-body.collapsed {
|
|
525
|
+
display: none;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.no-differences {
|
|
529
|
+
color: #28a745;
|
|
530
|
+
font-style: italic;
|
|
531
|
+
padding: 10px;
|
|
532
|
+
text-align: center;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
.diff-list {
|
|
536
|
+
list-style: none;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.diff-item {
|
|
540
|
+
padding: 12px 15px;
|
|
541
|
+
margin-bottom: 8px;
|
|
542
|
+
border-radius: 4px;
|
|
543
|
+
font-family: 'Courier New', Courier, monospace;
|
|
544
|
+
font-size: 13px;
|
|
545
|
+
line-height: 1.5;
|
|
546
|
+
border-left: 3px solid;
|
|
547
|
+
word-break: break-all;
|
|
548
|
+
position: relative;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.diff-item.removed {
|
|
552
|
+
background: #fff5f5;
|
|
553
|
+
border-left-color: #dc3545;
|
|
554
|
+
color: #721c24;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.diff-item.added {
|
|
558
|
+
background: #f0f9ff;
|
|
559
|
+
border-left-color: #28a745;
|
|
560
|
+
color: #155724;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
.diff-item .diff-marker {
|
|
564
|
+
display: inline-block;
|
|
565
|
+
width: 30px;
|
|
566
|
+
font-weight: bold;
|
|
567
|
+
margin-right: 10px;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
.diff-item .db-label {
|
|
571
|
+
display: inline-block;
|
|
572
|
+
padding: 2px 8px;
|
|
573
|
+
border-radius: 3px;
|
|
574
|
+
font-size: 11px;
|
|
575
|
+
font-weight: bold;
|
|
576
|
+
margin-right: 10px;
|
|
577
|
+
background: rgba(0,0,0,0.1);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
.diff-item.removed .db-label {
|
|
581
|
+
background: #dc3545;
|
|
582
|
+
color: white;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
.diff-item.added .db-label {
|
|
586
|
+
background: #28a745;
|
|
587
|
+
color: white;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.diff-explanation {
|
|
591
|
+
font-size: 12px;
|
|
592
|
+
font-style: italic;
|
|
593
|
+
color: #6c757d;
|
|
594
|
+
margin-bottom: 5px;
|
|
595
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.tooltip {
|
|
599
|
+
position: relative;
|
|
600
|
+
cursor: help;
|
|
601
|
+
border-bottom: 1px dotted #999;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.tooltip .tooltiptext {
|
|
605
|
+
visibility: hidden;
|
|
606
|
+
width: 300px;
|
|
607
|
+
background-color: #555;
|
|
608
|
+
color: #fff;
|
|
609
|
+
text-align: left;
|
|
610
|
+
border-radius: 6px;
|
|
611
|
+
padding: 8px 12px;
|
|
612
|
+
position: absolute;
|
|
613
|
+
z-index: 1;
|
|
614
|
+
bottom: 125%;
|
|
615
|
+
left: 50%;
|
|
616
|
+
margin-left: -150px;
|
|
617
|
+
opacity: 0;
|
|
618
|
+
transition: opacity 0.3s;
|
|
619
|
+
font-size: 12px;
|
|
620
|
+
font-style: normal;
|
|
621
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.tooltip .tooltiptext::after {
|
|
625
|
+
content: "";
|
|
626
|
+
position: absolute;
|
|
627
|
+
top: 100%;
|
|
628
|
+
left: 50%;
|
|
629
|
+
margin-left: -5px;
|
|
630
|
+
border-width: 5px;
|
|
631
|
+
border-style: solid;
|
|
632
|
+
border-color: #555 transparent transparent transparent;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
.tooltip:hover .tooltiptext {
|
|
636
|
+
visibility: visible;
|
|
637
|
+
opacity: 1;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.diff-content {
|
|
641
|
+
margin-top: 5px;
|
|
642
|
+
padding-left: 40px;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.schema-object {
|
|
646
|
+
display: inline-block;
|
|
647
|
+
background: #667eea;
|
|
648
|
+
color: white;
|
|
649
|
+
padding: 3px 10px;
|
|
650
|
+
border-radius: 4px;
|
|
651
|
+
font-size: 12px;
|
|
652
|
+
font-weight: 600;
|
|
653
|
+
margin-right: 8px;
|
|
654
|
+
font-family: 'Courier New', Courier, monospace;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
.object-detail {
|
|
658
|
+
font-weight: bold;
|
|
659
|
+
color: #333;
|
|
660
|
+
font-size: 14px;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.object-extra {
|
|
664
|
+
color: #6c757d;
|
|
665
|
+
font-size: 12px;
|
|
666
|
+
margin-top: 3px;
|
|
667
|
+
padding-left: 40px;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
.object-content {
|
|
671
|
+
font-family: 'Courier New', Courier, monospace;
|
|
672
|
+
font-size: 13px;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.collapsible-toggle {
|
|
676
|
+
background: #007bff;
|
|
677
|
+
color: white;
|
|
678
|
+
border: none;
|
|
679
|
+
padding: 4px 12px;
|
|
680
|
+
margin-left: 8px;
|
|
681
|
+
border-radius: 4px;
|
|
682
|
+
cursor: pointer;
|
|
683
|
+
font-size: 11px;
|
|
684
|
+
font-weight: 600;
|
|
685
|
+
transition: background 0.2s;
|
|
686
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
687
|
+
display: inline-block;
|
|
688
|
+
vertical-align: middle;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.collapsible-toggle:hover {
|
|
692
|
+
background: #0056b3;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.collapsible-toggle::before {
|
|
696
|
+
content: '\u25b6 ';
|
|
697
|
+
font-size: 10px;
|
|
698
|
+
transition: transform 0.2s;
|
|
699
|
+
display: inline-block;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.collapsible-toggle:not(.collapsed)::before {
|
|
703
|
+
content: '\u25bc ';
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
.collapsible-content {
|
|
707
|
+
max-height: 500px;
|
|
708
|
+
overflow: hidden;
|
|
709
|
+
transition: max-height 0.3s ease-out;
|
|
710
|
+
display: block;
|
|
711
|
+
width: 100%;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.collapsible-content.collapsed {
|
|
715
|
+
max-height: 0;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
.sql-widget {
|
|
719
|
+
background: #f8f9fa;
|
|
720
|
+
border: 1px solid #dee2e6;
|
|
721
|
+
border-radius: 4px;
|
|
722
|
+
padding: 12px;
|
|
723
|
+
margin-top: 4px;
|
|
724
|
+
max-height: 300px;
|
|
725
|
+
overflow-y: auto;
|
|
726
|
+
overflow-x: auto;
|
|
727
|
+
font-family: 'Courier New', Courier, monospace;
|
|
728
|
+
font-size: 12px;
|
|
729
|
+
line-height: 1.4;
|
|
730
|
+
white-space: pre-wrap;
|
|
731
|
+
word-wrap: break-word;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.sql-widget::-webkit-scrollbar {
|
|
735
|
+
width: 8px;
|
|
736
|
+
height: 8px;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.sql-widget::-webkit-scrollbar-track {
|
|
740
|
+
background: #f1f1f1;
|
|
741
|
+
border-radius: 4px;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
.sql-widget::-webkit-scrollbar-thumb {
|
|
745
|
+
background: #888;
|
|
746
|
+
border-radius: 4px;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
.sql-widget::-webkit-scrollbar-thumb:hover {
|
|
750
|
+
background: #555;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.footer {
|
|
754
|
+
padding: 20px 30px;
|
|
755
|
+
background: #f8f9fa;
|
|
756
|
+
border-top: 1px solid #e9ecef;
|
|
757
|
+
text-align: center;
|
|
758
|
+
color: #6c757d;
|
|
759
|
+
font-size: 14px;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
.expand-collapse {
|
|
763
|
+
color: #667eea;
|
|
764
|
+
font-size: 14px;
|
|
765
|
+
margin-bottom: 20px;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
.expand-collapse button {
|
|
769
|
+
background: none;
|
|
770
|
+
border: none;
|
|
771
|
+
color: #667eea;
|
|
772
|
+
cursor: pointer;
|
|
773
|
+
text-decoration: underline;
|
|
774
|
+
font-size: 14px;
|
|
775
|
+
padding: 5px 10px;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.expand-collapse button:hover {
|
|
779
|
+
color: #764ba2;
|
|
780
|
+
}
|
|
781
|
+
</style>
|
|
782
|
+
<script>
|
|
783
|
+
function toggleCollapsible(element) {
|
|
784
|
+
// Check if element is a button or a table header div
|
|
785
|
+
if (element.tagName === 'BUTTON') {
|
|
786
|
+
element.classList.toggle('collapsed');
|
|
787
|
+
// Find the corresponding collapsible-content div
|
|
788
|
+
let content = null;
|
|
789
|
+
const parent = element.parentElement;
|
|
790
|
+
|
|
791
|
+
// Check if collapsible-content is a sibling of the button (constraints/indexes/views/etc)
|
|
792
|
+
// or a sibling of the parent div (columns)
|
|
793
|
+
let hasSiblingContent = false;
|
|
794
|
+
let sibling = element.nextElementSibling;
|
|
795
|
+
while (sibling) {
|
|
796
|
+
if (sibling.classList && sibling.classList.contains('collapsible-content')) {
|
|
797
|
+
hasSiblingContent = true;
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
sibling = sibling.nextElementSibling;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (hasSiblingContent) {
|
|
804
|
+
// Content divs are siblings of buttons - match by index
|
|
805
|
+
const allButtons = [];
|
|
806
|
+
const allContents = [];
|
|
807
|
+
sibling = parent.firstElementChild;
|
|
808
|
+
|
|
809
|
+
while (sibling) {
|
|
810
|
+
if (sibling.tagName === 'BUTTON' && sibling.classList.contains('collapsible-toggle')) {
|
|
811
|
+
allButtons.push(sibling);
|
|
812
|
+
} else if (sibling.classList && sibling.classList.contains('collapsible-content')) {
|
|
813
|
+
allContents.push(sibling);
|
|
814
|
+
}
|
|
815
|
+
sibling = sibling.nextElementSibling;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const buttonIndex = allButtons.indexOf(element);
|
|
819
|
+
if (buttonIndex >= 0 && buttonIndex < allContents.length) {
|
|
820
|
+
content = allContents[buttonIndex];
|
|
821
|
+
}
|
|
822
|
+
} else {
|
|
823
|
+
// Content div is sibling of parent (columns case)
|
|
824
|
+
content = parent.nextElementSibling;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (content && content.classList.contains('collapsible-content')) {
|
|
828
|
+
content.classList.toggle('collapsed');
|
|
829
|
+
}
|
|
830
|
+
} else if (element.tagName === 'DIV') {
|
|
831
|
+
// Handle table header clicks
|
|
832
|
+
const schemaObject = element.querySelector('.schema-object');
|
|
833
|
+
if (schemaObject) {
|
|
834
|
+
const isExpanded = schemaObject.textContent.startsWith('▼');
|
|
835
|
+
schemaObject.textContent = (isExpanded ? '▶' : '▼') + schemaObject.textContent.substring(1);
|
|
836
|
+
}
|
|
837
|
+
const content = element.nextElementSibling;
|
|
838
|
+
if (content && content.classList.contains('collapsible-content')) {
|
|
839
|
+
content.classList.toggle('collapsed');
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
</script>
|
|
844
|
+
</head>
|
|
845
|
+
<body>
|
|
846
|
+
<div class="container">
|
|
847
|
+
<div class="header">
|
|
848
|
+
<h1>Database Comparison Report</h1>
|
|
849
|
+
<div class="meta">
|
|
850
|
+
<div>Database 1: <strong>{{ report.pg_connection1|e }}</strong></div>
|
|
851
|
+
<div>Database 2: <strong>{{ report.pg_connection2|e }}</strong></div>
|
|
852
|
+
<div>Generated: {{ report.timestamp.strftime('%Y-%m-%d %H:%M:%S')|e }}</div>
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
|
|
856
|
+
<div class="summary">
|
|
857
|
+
<div class="summary-card">
|
|
858
|
+
<div class="label">Total Checks</div>
|
|
859
|
+
<div class="value">{{ report.total_checks }}</div>
|
|
860
|
+
</div>
|
|
861
|
+
<div class="summary-card success">
|
|
862
|
+
<div class="label">Passed</div>
|
|
863
|
+
<div class="value">{{ report.passed_checks }}</div>
|
|
864
|
+
</div>
|
|
865
|
+
<div class="summary-card error">
|
|
866
|
+
<div class="label">Failed</div>
|
|
867
|
+
<div class="value">{{ report.failed_checks }}</div>
|
|
868
|
+
</div>
|
|
869
|
+
<div class="summary-card warning">
|
|
870
|
+
<div class="label">Total Differences</div>
|
|
871
|
+
<div class="value">{{ report.total_differences }}</div>
|
|
872
|
+
</div>
|
|
873
|
+
</div>
|
|
874
|
+
|
|
875
|
+
<div class="content">
|
|
876
|
+
<div class="expand-collapse">
|
|
877
|
+
<button onclick="expandAll()">Expand All</button> |
|
|
878
|
+
<button onclick="collapseAll()">Collapse All</button>
|
|
879
|
+
</div>
|
|
880
|
+
|
|
881
|
+
{% for result in report.check_results %}
|
|
882
|
+
<div class="check-section">
|
|
883
|
+
<div class="check-header" onclick="toggleSection('{{ result.key }}')">
|
|
884
|
+
<h2>
|
|
885
|
+
{{ result.name|e }}
|
|
886
|
+
<span class="badge {{ 'success' if result.passed else 'error' }}">
|
|
887
|
+
{{ 'PASSED' if result.passed else 'FAILED' }}
|
|
888
|
+
</span>
|
|
889
|
+
</h2>
|
|
890
|
+
<span class="diff-count">
|
|
891
|
+
{{ result.difference_count }} difference{{ 's' if result.difference_count != 1 else '' }}
|
|
892
|
+
</span>
|
|
893
|
+
</div>
|
|
894
|
+
<div id="{{ result.key }}" class="check-body{{ '' if not result.passed else ' collapsed' }}">
|
|
895
|
+
{% if not result.differences %}
|
|
896
|
+
<div class="no-differences">✓ No differences found</div>
|
|
897
|
+
{% else %}
|
|
898
|
+
{% if result.name == 'Columns' %}
|
|
899
|
+
{# Special handling for columns - group by table #}
|
|
900
|
+
{% set grouped = group_columns(result.differences) %}
|
|
901
|
+
{% for table, columns in grouped.items() %}
|
|
902
|
+
<div style="margin-bottom: 20px;">
|
|
903
|
+
<div style="margin-bottom: 10px; cursor: pointer;" onclick="toggleCollapsible(this)">
|
|
904
|
+
<span class="schema-object">▼ {{ table }}</span>
|
|
905
|
+
</div>
|
|
906
|
+
<div class="collapsible-content">
|
|
907
|
+
<ul class="diff-list">
|
|
908
|
+
{% for col in columns.removed %}
|
|
909
|
+
<li class="diff-item removed">
|
|
910
|
+
<div class="diff-content">
|
|
911
|
+
<span class="diff-marker">-</span>
|
|
912
|
+
<span class="db-label tooltip">
|
|
913
|
+
DB1
|
|
914
|
+
<span class="tooltiptext">
|
|
915
|
+
⚠️ Missing in <strong>{{ report.pg_connection2|e }}</strong><br>
|
|
916
|
+
Only exists in {{ report.pg_connection1|e }}
|
|
917
|
+
</span>
|
|
918
|
+
</span>
|
|
919
|
+
<span class="object-detail">{{ col.column }}</span>
|
|
920
|
+
{% if col.extra %}
|
|
921
|
+
<button class="collapsible-toggle collapsed" onclick="toggleCollapsible(this); event.stopPropagation();">Details</button>
|
|
922
|
+
{% endif %}
|
|
923
|
+
</div>
|
|
924
|
+
{% if col.extra %}
|
|
925
|
+
<div class="collapsible-content collapsed">
|
|
926
|
+
<div class="object-extra">{{ col.extra|e }}</div>
|
|
927
|
+
</div>
|
|
928
|
+
{% endif %}
|
|
929
|
+
</li>
|
|
930
|
+
{% endfor %}
|
|
931
|
+
{% for col in columns.added %}
|
|
932
|
+
<li class="diff-item added">
|
|
933
|
+
<div class="diff-content">
|
|
934
|
+
<span class="diff-marker">+</span>
|
|
935
|
+
<span class="db-label tooltip">
|
|
936
|
+
DB2
|
|
937
|
+
<span class="tooltiptext">
|
|
938
|
+
⚠️ Extra in <strong>{{ report.pg_connection2|e }}</strong><br>
|
|
939
|
+
Not present in {{ report.pg_connection1|e }}
|
|
940
|
+
</span>
|
|
941
|
+
</span>
|
|
942
|
+
<span class="object-detail">{{ col.column }}</span>
|
|
943
|
+
{% if col.extra %}
|
|
944
|
+
<button class="collapsible-toggle collapsed" onclick="toggleCollapsible(this); event.stopPropagation();">Details</button>
|
|
945
|
+
{% endif %}
|
|
946
|
+
</div>
|
|
947
|
+
{% if col.extra %}
|
|
948
|
+
<div class="collapsible-content collapsed">
|
|
949
|
+
<div class="object-extra">{{ col.extra|e }}</div>
|
|
950
|
+
</div>
|
|
951
|
+
{% endif %}
|
|
952
|
+
</li>
|
|
953
|
+
{% endfor %}
|
|
954
|
+
</ul>
|
|
955
|
+
</div>
|
|
956
|
+
</div>
|
|
957
|
+
{% endfor %}
|
|
958
|
+
{% else %}
|
|
959
|
+
<ul class="diff-list">
|
|
960
|
+
{% for diff in result.differences %}
|
|
961
|
+
{% set formatted = format_diff(diff.content, result.name) %}
|
|
962
|
+
<li class="diff-item {{ diff.type.value }}">
|
|
963
|
+
<div class="diff-content">
|
|
964
|
+
<span class="diff-marker">{{ '-' if diff.type.value == 'removed' else '+' }}</span>
|
|
965
|
+
<span class="db-label tooltip">
|
|
966
|
+
{{ 'DB1' if diff.type.value == 'removed' else 'DB2' }}
|
|
967
|
+
<span class="tooltiptext">
|
|
968
|
+
{% if diff.type.value == 'removed' %}
|
|
969
|
+
⚠️ Missing in <strong>{{ report.pg_connection2|e }}</strong><br>
|
|
970
|
+
Only exists in {{ report.pg_connection1|e }}
|
|
971
|
+
{% else %}
|
|
972
|
+
⚠️ Extra in <strong>{{ report.pg_connection2|e }}</strong><br>
|
|
973
|
+
Not present in {{ report.pg_connection1|e }}
|
|
974
|
+
{% endif %}
|
|
975
|
+
</span>
|
|
976
|
+
</span>
|
|
977
|
+
{% if formatted.is_structured %}
|
|
978
|
+
<span class="schema-object">{{ formatted.schema_object }}</span>
|
|
979
|
+
{% if formatted.detail %}
|
|
980
|
+
<span class="object-detail">{{ formatted.detail }}</span>
|
|
981
|
+
{% endif %}
|
|
982
|
+
{% if formatted.extra %}
|
|
983
|
+
<button class="collapsible-toggle collapsed" onclick="toggleCollapsible(this)">Details</button>
|
|
984
|
+
{% endif %}
|
|
985
|
+
{% if formatted.sql %}
|
|
986
|
+
<button class="collapsible-toggle collapsed" onclick="toggleCollapsible(this)">Definition</button>
|
|
987
|
+
{% endif %}
|
|
988
|
+
{% if formatted.extra %}
|
|
989
|
+
<div class="collapsible-content collapsed">
|
|
990
|
+
<div class="object-extra">{{ formatted.extra|e }}</div>
|
|
991
|
+
</div>
|
|
992
|
+
{% endif %}
|
|
993
|
+
{% if formatted.sql %}
|
|
994
|
+
<div class="collapsible-content collapsed">
|
|
995
|
+
<div class="sql-widget">{{ formatted.sql|e }}</div>
|
|
996
|
+
</div>
|
|
997
|
+
{% endif %}
|
|
998
|
+
{% else %}
|
|
999
|
+
<span class="object-content">{{ formatted.content|e }}</span>
|
|
1000
|
+
{% endif %}
|
|
1001
|
+
</div>
|
|
1002
|
+
</li>
|
|
1003
|
+
{% endfor %}
|
|
1004
|
+
</ul>
|
|
1005
|
+
{% endif %}
|
|
1006
|
+
{% endif %}
|
|
1007
|
+
</div>
|
|
1008
|
+
</div>
|
|
1009
|
+
{% endfor %}
|
|
1010
|
+
</div>
|
|
1011
|
+
|
|
1012
|
+
<div class="footer">
|
|
1013
|
+
Generated by PUM Database Checker
|
|
1014
|
+
</div>
|
|
1015
|
+
</div>
|
|
1016
|
+
|
|
1017
|
+
<script>
|
|
1018
|
+
function toggleSection(id) {
|
|
1019
|
+
const element = document.getElementById(id);
|
|
1020
|
+
element.classList.toggle('collapsed');
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function expandAll() {
|
|
1024
|
+
document.querySelectorAll('.check-body').forEach(el => {
|
|
1025
|
+
el.classList.remove('collapsed');
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function collapseAll() {
|
|
1030
|
+
document.querySelectorAll('.check-body').forEach(el => {
|
|
1031
|
+
el.classList.add('collapsed');
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
</script>
|
|
1035
|
+
</body>
|
|
1036
|
+
</html>"""
|
|
1037
|
+
|
|
1038
|
+
template = Template(template_str)
|
|
1039
|
+
return template.render(
|
|
1040
|
+
report=report,
|
|
1041
|
+
format_diff=ReportGenerator._format_difference,
|
|
1042
|
+
group_columns=ReportGenerator._group_columns_by_table,
|
|
1043
|
+
)
|