Source code for oci_policy_analysis.presentation.qt.details

"""Reusable animated, read-only source-record drawer for Qt pages."""

import json

from PySide6.QtCore import QEasingCurve, QEvent, QPoint, QPropertyAnimation, Qt
from PySide6.QtGui import QColor, QKeySequence, QShortcut
from PySide6.QtWidgets import (
    QApplication,
    QFrame,
    QGraphicsDropShadowEffect,
    QHBoxLayout,
    QPlainTextEdit,
    QPushButton,
    QScrollArea,
    QTabWidget,
    QVBoxLayout,
    QWidget,
)

from oci_policy_analysis.presentation.qt.json_view import JsonTree
from oci_policy_analysis.presentation.qt.widgets import CollapsibleSection, label

_GROUPS = [
    (
        'Basic Details',
        (
            'statement_text',
            'policy_name',
            'policy_ocid',
            'compartment_path',
            'compartment_ocid',
            'creation_time',
            'effective_path',
        ),
    ),
    (
        'Validity',
        (
            'valid',
            'invalid',
            'invalid_reasons',
            'invalid_reason',
            'parsed',
            'parse_errors',
            'parsing_notes',
            'confidence',
            'match_confidence',
            'match_confidence_reason',
        ),
    ),
    (
        'Parsed statement',
        (
            'action',
            'subject_type',
            'subject',
            'principals',
            'principal_keys',
            'verb',
            'resource',
            'permission',
            'location_type',
            'location',
            'effective_compartment_ocid',
            'comments',
        ),
    ),
    (
        'Cross-tenancy',
        (
            'kind',
            'direction',
            'tenancy_aliases',
            'resolved_aliases',
            'aliases_resolved',
            'principal_identity',
            'external_tenancies',
            'participants',
            'association',
            'tenancy_associations',
            'reciprocal_guidance',
        ),
    ),
    (
        'Define details',
        ('defined_type', 'defined_name', 'ocid_alias', 'definition_status', 'reference_status', 'reference_count'),
    ),
    (
        'Admit details',
        (
            'admitted_principal_type',
            'admitted_principal',
            'admitted_tenancy',
            'admitted_principal_tenancy',
            'admit_action',
            'admit_resource',
            'admit_permissions_original',
            'admit_permissions',
            'admit_location_type',
            'admit_location',
            'admit_associate_resource',
            'admit_associate_tenancy',
            'admit_associate_with_resource',
            'admit_associate_with_tenancy',
            'associate_clause_raw',
        ),
    ),
    (
        'Endorse details',
        (
            'endorsed_principal_type',
            'endorsed_principal',
            'endorsed_principal_tenancy',
            'endorse_action',
            'endorse_resource',
            'endorse_permissions_original',
            'endorse_permissions',
            'endorse_tenancy',
            'endorse_location_type',
            'endorse_location',
            'endorse_associate_resource',
            'endorse_associate_tenancy',
            'endorse_associate_with_resource',
            'endorse_associate_with_tenancy',
            'associate_clause_raw',
        ),
    ),
    ('Statement comment and condition', ('comment', 'where_clause')),
    (
        'Permission facts',
        ('graph_selection', 'permission_facts'),
    ),
    (
        'Conditions',
        (
            'conditions',
            'conditions_where_clause',
            'conditions_parsed_structure',
            'condition_atoms',
            'where_clause',
            'where_clause_structure',
        ),
    ),
]


[docs] class DetailsDrawer(QFrame): """Slide a source record in from the right, keeping every supplied field. Attributes: record: Currently inspected source dictionary, or None after clear/reload. raw: Read-only original record serialization, including all optional fields. Notes: Never parses/reinterprets policy evidence or modifies the loaded snapshot. Nested values have their own scroll area; source values are plain text. """ def __init__(self, parent: QWidget) -> None: """Install a right-edge overlay within the shell's central widget.""" super().__init__(parent) self.setObjectName('detailsDrawer') self.record = None self._grouped = True self._closing = False self.animation = QPropertyAnimation(self, b'pos', self) self.animation.setDuration(180) self.animation.setEasingCurve(QEasingCurve.Type.OutCubic) self.animation.finished.connect(self._animation_finished) shadow = QGraphicsDropShadowEffect(self) shadow.setBlurRadius(25) shadow.setOffset(-4, 2) shadow.setColor(QColor(0, 0, 0, 50)) self.setGraphicsEffect(shadow) layout = QVBoxLayout(self) layout.setContentsMargins(18, 16, 18, 16) row = QHBoxLayout() self.title = label('Statement details', 'eyebrow') row.addWidget(self.title) row.addStretch() close = QPushButton('Close ×') close.clicked.connect(self.dismiss) row.addWidget(close) layout.addLayout(row) self.status = label('', wrap=True) layout.addWidget(self.status) self.invalid_notice = QPlainTextEdit() self.invalid_notice.setReadOnly(True) self.invalid_notice.setAccessibleName('Invalid statement reason') self.invalid_notice.setMaximumHeight(120) self.invalid_notice.setMinimumHeight(80) self.invalid_notice.setStyleSheet('border-left: 3px solid #c3424b;') self.invalid_notice.hide() layout.addWidget(self.invalid_notice) tabs = QTabWidget() self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) self.scroll.setFrameShape(QFrame.Shape.NoFrame) tabs.addTab(self.scroll, 'Details') self.raw = QPlainTextEdit() self.raw.setReadOnly(True) tabs.addTab(self.raw, 'Raw record') layout.addWidget(tabs, 1) actions = QHBoxLayout() copy = QPushButton('Copy JSON') copy.clicked.connect(lambda: QApplication.clipboard().setText(self.raw.toPlainText())) actions.addWidget(copy) self.copy_statement = QPushButton('Copy statement') self.copy_statement.clicked.connect( lambda: QApplication.clipboard().setText(str((self.record or {}).get('statement_text') or '')) ) actions.addWidget(self.copy_statement) actions.addStretch() layout.addLayout(actions) shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self) shortcut.activated.connect(self.dismiss) parent.installEventFilter(self) self.hide()
[docs] def show_finding(self, record: dict | None) -> None: """Show all backing-service finding fields without collapsible sections. Args: record: Finding dictionary, or None to clear the drawer. """ self.show_record(record, 'Recommendation details', grouped=False)
[docs] def show_record(self, record: dict | None, title: str = 'Statement details', *, grouped: bool = True) -> None: """Render retained fields and animate the first opening of the drawer. Args: record: Source or finding dictionary, or None to clear the drawer. title: Drawer heading. grouped: Group source evidence into sections. False shows every field, including empty values, in backing-service insertion order. """ if record is None: self.clear() return if record is self.record and grouped == self._grouped and self.isVisible() and not self._closing: return opening = self.isHidden() or self._closing self.record = record self._grouped = grouped self._closing = False self.animation.stop() self.title.setText(title) valid = record.get('valid') validity = 'Valid' if valid is True else 'Invalid' if valid is False else 'Validity not recorded' if 'statement_text' not in record: validity = str(record.get('dynamic_group_name') or 'Source record') parsed = record.get('parsed') parsed_status = 'Yes' if parsed is True else 'No' if parsed is False else 'Not recorded' self.status.setText(f'{validity} · Parsed: {parsed_status}' if 'statement_text' in record else validity) self.status.setVisible(grouped) self.invalid_notice.setVisible(grouped and valid is False) if valid is False: reasons = [record[key] for key in ('invalid_reasons', 'invalid_reason', 'parse_errors') if record.get(key)] if not reasons and record.get('parsing_notes'): reasons = [record['parsing_notes']] summary = ( '\n'.join( '• ' + ( json.dumps(reason, ensure_ascii=False, default=str) if isinstance(reason, (list, dict, tuple)) else str(reason) ) for value in reasons for reason in (value if isinstance(value, (list, tuple)) else [value]) ) or 'No invalid reason was recorded. Review Validity and Raw record.' ) self.invalid_notice.setPlainText('Invalid statement\n' + summary) else: self.invalid_notice.clear() self.raw.setPlainText(json.dumps(record, indent=2, ensure_ascii=False, default=str)) self.copy_statement.setVisible('statement_text' in record) content = QWidget() fields = QVBoxLayout(content) fields.setContentsMargins(0, 12, 8, 0) seen = set() groups = ( _GROUPS if 'statement_text' in record else [ ( 'Identity', ( 'domain_name', 'domain_ocid', 'dynamic_group_name', 'dynamic_group_ocid', 'dynamic_group_id', 'description', 'in_use', 'creation_time', 'created_by_name', 'created_by_ocid', ), ), ( 'Matching rule', ( 'matching_rule', 'matching_rule_structure', 'matching_rule_parsed_structure', 'matching_rule_elements', ), ), ] ) if record.get('user_name'): identity_name, identity_keys = groups[0] identity_keys = tuple(identity_keys) + tuple( key for key in record if key not in {item for _, keys in groups for item in keys} ) groups[0] = (identity_name, identity_keys) if valid is False and 'statement_text' in record: groups = [group for group in groups if group[0] == 'Validity'] + [ group for group in groups if group[0] != 'Validity' ] cross_tenancy_finding = title == 'Cross-tenancy finding details' if cross_tenancy_finding: groups = [ ( 'Finding', ('Type', 'Severity', 'Policy Name', 'Issue', 'Statement Text', 'source_ids', 'related_statements'), ) ] consolidation_finding = grouped and 'Opportunity ID' in record and 'Evidence' in record if consolidation_finding: groups = [('Recommendation', tuple(record))] display_groups = list(groups) if not grouped: display_groups = [('', tuple(record))] elif not cross_tenancy_finding and not consolidation_finding: display_groups.append( ( 'Additional Details', tuple( key for key in record if key != 'parsed_fields' and key not in {key for _, keys in groups for key in keys} ), ) ) for name, keys in display_groups: selected = [ key for key in keys if key in record and key not in seen and (not grouped or record[key] not in (None, '', [], {}, ())) ] if not selected: continue group = QWidget() values = QVBoxLayout(group) for key in selected: seen.add(key) values.addWidget(label(key.replace('_', ' ').title(), 'eyebrow')) value = record[key] if consolidation_finding and key == 'Evidence': text = JsonTree() text.set_json(value) text.expandToDepth(2) text.setMinimumHeight(340) elif key in {'invalid_reasons', 'invalid_reason', 'parse_errors'}: reasons = value if isinstance(value, (list, tuple)) else [value] text = label( '\n'.join( '• ' + ( json.dumps(reason, ensure_ascii=False, default=str) if isinstance(reason, (dict, list, tuple)) else str(reason) ) for reason in reasons ), wrap=True, ) text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) elif isinstance(value, list | tuple): text = label( '\n'.join( '• ' + ( json.dumps(item, ensure_ascii=False, default=str) if isinstance(item, dict | list | tuple) else str(item) ) for item in value ) or 'None recorded', wrap=True, ) text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) elif isinstance(value, dict): rendered = json.dumps(value, indent=2, ensure_ascii=False, default=str) if grouped: text = QPlainTextEdit() text.setReadOnly(True) text.setPlainText(rendered) text.setFixedHeight(115 if value else 55) else: text = label(rendered, wrap=True) text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) else: text = label(str(value) if value is not None else 'Not recorded', wrap=True) text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) values.addWidget(text) if not grouped: values.setContentsMargins(0, 0, 0, 0) fields.addWidget(group) continue section = CollapsibleSection(name, group) section.button.setChecked( name in { 'Recommendation', 'Basic Details', 'Parsed statement', 'Cross-tenancy', 'Permission facts', 'Identity', 'Matching rule', } or (name == 'Validity' and valid is False) ) fields.addWidget(section) fields.addStretch() previous = self.scroll.takeWidget() if previous: previous.deleteLater() self.scroll.setWidget(content) self.scroll.verticalScrollBar().setValue(0) self.resize( min(720, max(480, int(self.parentWidget().width() * 0.54))), max(180, self.parentWidget().height() - 24) ) target = self._target() self.move(QPoint(self.parentWidget().width(), 12) if opening else target) self.show() self.raise_() if opening: self.animation.setStartValue(self.pos()) self.animation.setEndValue(target) self.animation.start()
[docs] def dismiss(self) -> None: """Slide out; keep the source selection available for reopening by click.""" if self.isHidden(): return self._closing = True self.animation.stop() self.animation.setStartValue(self.pos()) self.animation.setEndValue(QPoint(self.parentWidget().width(), 12)) self.animation.start()
[docs] def clear(self) -> None: """Immediately discard old snapshot evidence on reload, reset, or navigation.""" self.animation.stop() self.record = None self._closing = False self.raw.clear() self.hide()
def _target(self) -> QPoint: return QPoint(max(0, self.parentWidget().width() - self.width() - 12), 12) def _animation_finished(self) -> None: if self._closing: self.hide() self._closing = False
[docs] def eventFilter(self, watched, event) -> bool: """Anchor to the shell's right edge after a resize, including dock changes.""" if event.type() == QEvent.Type.Resize and self.isVisible(): if self._closing: self.clear() return False self.animation.stop() self.resize(self.width(), max(180, self.parentWidget().height() - 24)) self.move(self._target()) return super().eventFilter(watched, event)