pum 1.2.3__py3-none-any.whl → 1.3.1__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.
@@ -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
+ )