# gui/unified_view_model.py import logging # Added for debugging log = logging.getLogger(__name__) # Added for debugging from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal # Added Signal from PySide6.QtGui import QColor # Added for background role from pathlib import Path # Added for file_name extraction from rule_structure import SourceRule, AssetRule, FileRule # Removed AssetType, ItemType import from configuration import load_base_config # Import load_base_config from typing import List # Added for type hinting class UnifiedViewModel(QAbstractItemModel): # --- Color Constants for Row Backgrounds --- # Old colors removed, using config now + fixed source color SOURCE_RULE_COLOR = QColor("#306091") # Fixed color for SourceRule rows # ----------------------------------------- """ A QAbstractItemModel for displaying and editing the hierarchical structure of SourceRule -> AssetRule -> FileRule. """ Columns = [ "Name", "Target Asset", "Supplier", "Asset Type", "Item Type" ] COL_NAME = 0 COL_TARGET_ASSET = 1 COL_SUPPLIER = 2 COL_ASSET_TYPE = 3 COL_ITEM_TYPE = 4 # COL_STATUS = 5 # Removed # COL_OUTPUT_PATH = 6 # Removed def __init__(self, parent=None): super().__init__(parent) self._source_rules = [] # Now stores a list of SourceRule objects def load_data(self, source_rules_list: list): # Accepts a list """Loads or reloads the model with a list of SourceRule objects.""" self.beginResetModel() self._source_rules = source_rules_list if source_rules_list else [] # Assign the new list # Ensure back-references for parent lookup are set on the NEW items for source_rule in self._source_rules: for asset_rule in source_rule.assets: asset_rule.parent_source = source_rule # Set parent SourceRule for file_rule in asset_rule.files: file_rule.parent_asset = asset_rule # Set parent AssetRule self.endResetModel() def clear_data(self): """Clears the model data.""" self.beginResetModel() self._source_rules = [] # Clear the list self.endResetModel() def get_all_source_rules(self) -> list: """Returns the internal list of SourceRule objects.""" return self._source_rules def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: """Returns the number of rows under the given parent.""" if not parent.isValid(): # Parent is the invisible root. Children are the SourceRules. return len(self._source_rules) parent_item = parent.internalPointer() if isinstance(parent_item, SourceRule): # Parent is a SourceRule. Children are AssetRules. return len(parent_item.assets) elif isinstance(parent_item, AssetRule): # Parent is an AssetRule. Children are FileRules. return len(parent_item.files) elif isinstance(parent_item, FileRule): return 0 # FileRules have no children return 0 # Should not happen for valid items def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: """Returns the number of columns.""" return len(self.Columns) def parent(self, index: QModelIndex) -> QModelIndex: """Returns the parent of the model item with the given index.""" if not index.isValid(): return QModelIndex() child_item = index.internalPointer() if child_item is None: return QModelIndex() # Determine the parent based on the item type if isinstance(child_item, SourceRule): # Parent is the invisible root return QModelIndex() elif isinstance(child_item, AssetRule): # Parent is a SourceRule. Find its row in the _source_rules list. parent_item = getattr(child_item, 'parent_source', None) if parent_item and parent_item in self._source_rules: try: parent_row = self._source_rules.index(parent_item) return self.createIndex(parent_row, 0, parent_item) except ValueError: return QModelIndex() # Should not happen if parent_source is correct else: return QModelIndex() # Parent SourceRule not found or reference missing elif isinstance(child_item, FileRule): # Parent is an AssetRule. Find its row within its parent SourceRule. parent_item = getattr(child_item, 'parent_asset', None) # Get parent AssetRule if parent_item: grandparent_item = getattr(parent_item, 'parent_source', None) # Get the SourceRule if grandparent_item: try: parent_row = grandparent_item.assets.index(parent_item) # We need the index of the grandparent (SourceRule) to create the parent index grandparent_row = self._source_rules.index(grandparent_item) return self.createIndex(parent_row, 0, parent_item) # Create index for the AssetRule parent except ValueError: return QModelIndex() # Parent AssetRule or Grandparent SourceRule not found in respective lists else: return QModelIndex() # Grandparent (SourceRule) reference missing else: return QModelIndex() # Parent AssetRule reference missing return QModelIndex() # Should not be reached def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex: """Returns the index of the item in the model specified by the given row, column and parent index.""" if not self.hasIndex(row, column, parent): return QModelIndex() parent_item = None if not parent.isValid(): # Parent is invisible root. Children are SourceRules. if row < len(self._source_rules): child_item = self._source_rules[row] return self.createIndex(row, column, child_item) else: return QModelIndex() # Row out of bounds for top-level items else: # Parent is a valid index, get its item parent_item = parent.internalPointer() child_item = None if isinstance(parent_item, SourceRule): # Parent is SourceRule. Children are AssetRules. if row < len(parent_item.assets): child_item = parent_item.assets[row] # Ensure parent reference is set if not hasattr(child_item, 'parent_source'): child_item.parent_source = parent_item elif isinstance(parent_item, AssetRule): # Parent is AssetRule. Children are FileRules. if row < len(parent_item.files): child_item = parent_item.files[row] # Ensure parent reference is set if not hasattr(child_item, 'parent_asset'): child_item.parent_asset = parent_item if child_item: # Create index for the child item under the parent return self.createIndex(row, column, child_item) else: # Invalid row or parent type has no children (FileRule) return QModelIndex() def data(self, index: QModelIndex, role: int = Qt.DisplayRole): """Returns the data stored under the given role for the item referred to by the index.""" if not index.isValid(): # Check only index validity, data list might be empty but valid return None item = index.internalPointer() column = index.column() # --- Handle Background Role --- if role == Qt.BackgroundRole: # item is already fetched at line 172 if isinstance(item, SourceRule): return self.SOURCE_RULE_COLOR # Use the class constant elif isinstance(item, AssetRule): # Determine effective asset type asset_type = item.asset_type_override if item.asset_type_override else item.asset_type if asset_type: try: base_config = load_base_config() # Load base config asset_type_definitions = base_config.get('ASSET_TYPE_DEFINITIONS', {}) # Get definitions type_info = asset_type_definitions.get(asset_type) if type_info: hex_color = type_info.get("color") if hex_color: try: return QColor(hex_color) except ValueError: # Optional: Add logging for invalid hex color # print(f"Warning: Invalid hex color '{hex_color}' for asset type '{asset_type}' in config.") return None # Fallback for invalid hex else: # Optional: Add logging for missing color key # print(f"Warning: No color defined for asset type '{asset_type}' in config.") return None # Fallback if color key missing else: # Optional: Add logging for missing asset type definition # print(f"Warning: Asset type '{asset_type}' not found in ASSET_TYPE_DEFINITIONS.") return None # Fallback if type not in config except Exception: # Catch errors during config loading return None # Fallback on error else: return None # Fallback if no asset_type determined elif isinstance(item, FileRule): # Determine effective item type: Prioritize override, then use base type effective_item_type = item.item_type_override if item.item_type_override is not None else item.item_type if effective_item_type: try: base_config = load_base_config() # Load base config file_type_definitions = base_config.get('FILE_TYPE_DEFINITIONS', {}) # Get definitions type_info = file_type_definitions.get(effective_item_type) if type_info: hex_color = type_info.get("color") if hex_color: try: return QColor(hex_color) except ValueError: # Optional: Add logging for invalid hex color # print(f"Warning: Invalid hex color '{hex_color}' for file type '{item_type}' in config.") return None # Fallback for invalid hex else: # Optional: Add logging for missing color key # print(f"Warning: No color defined for file type '{item_type}' in config.") return None # Fallback if color key missing else: # File types often don't have specific colors, so no warning needed unless debugging return None # Fallback if type not in config except Exception: # Catch errors during config loading return None # Fallback on error else: return None # Fallback if no item_type determined else: # Other item types or if item is None return None # --- Handle other roles (Display, Edit, etc.) --- if isinstance(item, SourceRule): if role == Qt.DisplayRole or role == Qt.EditRole: # Combine Display and Edit logic if column == self.COL_NAME: return Path(item.input_path).name elif column == self.COL_SUPPLIER: # Return override if set, otherwise the original identifier, else empty string display_value = item.supplier_override if item.supplier_override is not None else item.supplier_identifier return display_value if display_value is not None else "" # Other columns return None or "" for SourceRule in Display/Edit roles return None # Default for SourceRule for other roles/columns elif isinstance(item, AssetRule): if role == Qt.DisplayRole: if column == self.COL_NAME: return item.asset_name elif column == self.COL_ASSET_TYPE: display_value = item.asset_type_override if item.asset_type_override is not None else item.asset_type return display_value if display_value else "" # Removed Status and Output Path columns elif role == Qt.EditRole: if column == self.COL_ASSET_TYPE: return item.asset_type_override return None # Default for AssetRule elif isinstance(item, FileRule): if role == Qt.DisplayRole: if column == self.COL_NAME: return Path(item.file_path).name # Display only filename elif column == self.COL_TARGET_ASSET: return item.target_asset_name_override if item.target_asset_name_override is not None else "" elif column == self.COL_ITEM_TYPE: # Reverted Logic: Display override if set, otherwise base type. Shows prefixed keys. override = item.item_type_override initial_type = item.item_type if override is not None: return override else: return initial_type if initial_type else "" # Removed Status and Output Path columns elif role == Qt.EditRole: if column == self.COL_TARGET_ASSET: return item.target_asset_name_override if item.target_asset_name_override is not None else "" # Return string or "" elif column == self.COL_ITEM_TYPE: return item.item_type_override # Return string or None return None # Default for FileRule return None # Default return if role/item combination not handled def setData(self, index: QModelIndex, value, role: int = Qt.EditRole) -> bool: """Sets the role data for the item at index to value.""" if not index.isValid() or role != Qt.EditRole: # Check only index and role return False item = index.internalPointer() if item is None: # Extra check for safety return False column = index.column() changed = False # --- Handle different item types --- if isinstance(item, SourceRule): # If SourceRule is editable if column == self.COL_SUPPLIER: # Get the new value, strip whitespace, treat empty as None log.debug(f"setData COL_SUPPLIER: Index=({index.row()},{column}), Value='{value}', Type={type(value)}") # <-- ADDED LOGGING (Corrected Indentation) new_value = str(value).strip() if value is not None and str(value).strip() else None # Get the original identifier (assuming it exists on SourceRule) original_identifier = getattr(item, 'supplier_identifier', None) # If the new value is the same as the original, clear the override if new_value == original_identifier: new_value = None # Effectively removes the override # Update supplier_override only if it's different if item.supplier_override != new_value: item.supplier_override = new_value changed = True elif isinstance(item, AssetRule): if column == self.COL_ASSET_TYPE: # Delegate provides string value (e.g., "Surface", "Model") or None new_value = str(value) if value is not None else None if new_value == "": new_value = None # Treat empty string as None # Update asset_type_override if item.asset_type_override != new_value: item.asset_type_override = new_value changed = True elif isinstance(item, FileRule): if column == self.COL_TARGET_ASSET: # Target Asset Name Override # Ensure value is string or None new_value = str(value).strip() if value is not None else None if new_value == "": new_value = None # Treat empty string as None # Update target_asset_name_override if item.target_asset_name_override != new_value: old_value = item.target_asset_name_override # Store old value for potential revert/comparison item.target_asset_name_override = new_value changed = True # --- Start: New Direct Model Restructuring Logic --- old_parent_asset = getattr(item, 'parent_asset', None) if old_parent_asset: # Ensure we have the old parent source_rule = getattr(old_parent_asset, 'parent_source', None) if source_rule: # Ensure we have the grandparent new_target_name = new_value # Can be None or a string # Get old parent index and source row try: grandparent_row = self._source_rules.index(source_rule) old_parent_row = source_rule.assets.index(old_parent_asset) source_row = old_parent_asset.files.index(item) old_parent_index = self.createIndex(old_parent_row, 0, old_parent_asset) grandparent_index = self.createIndex(grandparent_row, 0, source_rule) # Needed for insert/remove parent except ValueError: print("Error: Could not find item, parent, or grandparent in model structure during setData.") item.target_asset_name_override = old_value # Revert data change return False # Indicate failure target_parent_asset = None target_parent_index = QModelIndex() target_parent_row = -1 # Row within source_rule.assets target_row = -1 # Row within target_parent_asset.files move_occurred = False # Flag to track if a move happened # 1. Find existing target parent if new_target_name: # Only search if a specific target is given for i, asset in enumerate(source_rule.assets): if asset.asset_name == new_target_name: target_parent_asset = asset target_parent_row = i target_parent_index = self.createIndex(target_parent_row, 0, target_parent_asset) break # 2. Handle Move/Creation if target_parent_asset: # --- Move to Existing Parent --- if target_parent_asset != old_parent_asset: # Don't move if target is the same as old parent target_row = len(target_parent_asset.files) # Append to the end # print(f"DEBUG: Moving {Path(item.file_path).name} from {old_parent_asset.asset_name} ({source_row}) to {target_parent_asset.asset_name} ({target_row})") self.beginMoveRows(old_parent_index, source_row, source_row, target_parent_index, target_row) # Restructure internal data old_parent_asset.files.pop(source_row) target_parent_asset.files.append(item) item.parent_asset = target_parent_asset # Update parent reference self.endMoveRows() move_occurred = True else: # Target is the same as the old parent. No move needed. pass elif new_target_name: # Only create if a *new* specific target name was given # --- Create New Parent and Move --- # print(f"DEBUG: Creating new parent '{new_target_name}' and moving {Path(item.file_path).name}") # Create new AssetRule new_asset_rule = AssetRule(asset_name=new_target_name) new_asset_rule.asset_type = old_parent_asset.asset_type # Copy type from old parent new_asset_rule.asset_type_override = old_parent_asset.asset_type_override # Copy override too new_asset_rule.parent_source = source_rule # Set parent reference # Determine insertion row for the new parent (e.g., append) new_parent_row = len(source_rule.assets) # print(f"DEBUG: Inserting new parent at row {new_parent_row} under {Path(source_rule.input_path).name}") # Emit signals for inserting the new parent row self.beginInsertRows(grandparent_index, new_parent_row, new_parent_row) source_rule.assets.insert(new_parent_row, new_asset_rule) # Insert into data structure self.endInsertRows() # Get index for the newly inserted parent target_parent_index = self.createIndex(new_parent_row, 0, new_asset_rule) target_row = 0 # Insert file at the beginning of the new parent (for signal) # Emit signals for moving the file row # print(f"DEBUG: Moving {Path(item.file_path).name} from {old_parent_asset.asset_name} ({source_row}) to new {new_asset_rule.asset_name} ({target_row})") self.beginMoveRows(old_parent_index, source_row, source_row, target_parent_index, target_row) # Restructure internal data old_parent_asset.files.pop(source_row) new_asset_rule.files.append(item) # Append is fine, target_row=0 was for signal item.parent_asset = new_asset_rule # Update parent reference self.endMoveRows() move_occurred = True # Update target_parent_asset for potential cleanup check later target_parent_asset = new_asset_rule else: # new_target_name is None or empty # No move happens when the override is simply cleared. pass # 3. Cleanup Empty Old Parent (only if a move occurred and old parent is empty) if move_occurred and not old_parent_asset.files: # print(f"DEBUG: Removing empty old parent {old_parent_asset.asset_name}") try: # Find the row of the old parent again, as it might have shifted old_parent_row_for_removal = source_rule.assets.index(old_parent_asset) # print(f"DEBUG: Removing parent at row {old_parent_row_for_removal} under {Path(source_rule.input_path).name}") self.beginRemoveRows(grandparent_index, old_parent_row_for_removal, old_parent_row_for_removal) source_rule.assets.pop(old_parent_row_for_removal) self.endRemoveRows() except ValueError: print(f"Error: Could not find old parent '{old_parent_asset.asset_name}' for removal.") # Log error, but continue else: print("Error: Could not find grandparent SourceRule during setData restructuring.") item.target_asset_name_override = old_value # Revert return False else: print("Error: Could not find parent AssetRule during setData restructuring.") item.target_asset_name_override = old_value # Revert return False # --- End: New Direct Model Restructuring Logic --- elif column == self.COL_ITEM_TYPE: # Item-Type Override # Delegate provides string value (e.g., "MAP_COL") or None new_value = str(value) if value is not None else None if new_value == "": new_value = None # Treat empty string as None # Update item_type_override if item.item_type_override != new_value: log.debug(f"setData COL_ITEM_TYPE: File='{Path(item.file_path).name}', Original Override='{item.item_type_override}', Original Standard='{getattr(item, 'standard_map_type', 'N/A')}', New Value='{new_value}'") # DEBUG LOG - Added getattr for safety old_override = item.item_type_override # Store old value for logging item.item_type_override = new_value changed = True # --- BEGIN FIX: Update standard_map_type --- try: base_config = load_base_config() file_type_definitions = base_config.get('FILE_TYPE_DEFINITIONS', {}) # Determine the type to look up (override first, then original) type_to_lookup = new_value if new_value is not None else item.item_type new_standard_type = None if type_to_lookup: type_info = file_type_definitions.get(type_to_lookup) if type_info: new_standard_type = type_info.get("standard_type") # If standard_type itself is missing in the definition, treat as None or keep old? Let's default to None. if new_standard_type is None: log.warning(f"setData: No 'standard_type' defined for item type '{type_to_lookup}' in FILE_TYPE_DEFINITIONS.") else: log.warning(f"setData: Item type '{type_to_lookup}' not found in FILE_TYPE_DEFINITIONS.") # Fallback: Keep the existing standard_map_type if lookup fails completely new_standard_type = getattr(item, 'standard_map_type', None) else: # If both override and original type are None, standard type should be None new_standard_type = None # Update the standard_map_type if it changed or needs setting current_standard_type = getattr(item, 'standard_map_type', None) if current_standard_type != new_standard_type: item.standard_map_type = new_standard_type log.debug(f"setData: Updated standard_map_type from '{current_standard_type}' to '{new_standard_type}' for file '{Path(item.file_path).name}' based on type '{type_to_lookup}'") # No need to set 'changed = True' again, already set above except Exception as e: log.exception(f"setData: Error updating standard_map_type for file '{Path(item.file_path).name}': {e}") # --- END FIX --- log.debug(f"setData COL_ITEM_TYPE: File='{Path(item.file_path).name}', Final Override='{item.item_type_override}', Final Standard='{getattr(item, 'standard_map_type', 'N/A')}'") # DEBUG LOG - Updated if changed: # Emit dataChanged for the specific index and affected roles self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole]) return True return False def flags(self, index: QModelIndex) -> Qt.ItemFlags: """Returns the item flags for the given index.""" if not index.isValid(): return Qt.NoItemFlags # No flags for invalid index # Start with default flags for a valid item default_flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable item = index.internalPointer() column = index.column() can_edit = False # Determine editability based on item type and column if isinstance(item, SourceRule): # If SourceRule is displayed/editable if column == self.COL_SUPPLIER: can_edit = True # Supplier is editable elif isinstance(item, AssetRule): if column == self.COL_ASSET_TYPE: can_edit = True # Asset Type is editable elif isinstance(item, FileRule): if column == self.COL_TARGET_ASSET: can_edit = True # Target Asset is editable if column == self.COL_ITEM_TYPE: can_edit = True # Item Type is editable if can_edit: return default_flags | Qt.ItemIsEditable else: return default_flags def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.DisplayRole): """Returns the data for the given role and section in the header.""" if orientation == Qt.Horizontal and role == Qt.DisplayRole: if 0 <= section < len(self.Columns): return self.Columns[section] # Optionally handle Vertical header (row numbers) # if orientation == Qt.Vertical and role == Qt.DisplayRole: # return str(section + 1) return None # Helper to get item from index def getItem(self, index: QModelIndex): """Safely returns the item associated with the index.""" if index.isValid(): item = index.internalPointer() if item: # Ensure internal pointer is not None return item return None # Return None for invalid index or None pointer # --- Method to update model based on LLM predictions --- def update_rules_for_sources(self, source_rules: List[SourceRule]): """ Updates the model's internal data based on a list of SourceRule objects, typically containing predictions for one or more source directories. Args: source_rules: A list of SourceRule objects containing the new structure. """ if not source_rules: print("UnifiedViewModel: update_rules_for_sources called with empty list.") return # --- Important: Model Change Signaling --- # Using Option 2 (per-source update) as it's generally more efficient. print(f"UnifiedViewModel: Updating rules for {len(source_rules)} source(s).") # --- Node Class Placeholders --- # Ensure these match your actual node implementation if different. # These might be imported from another module or defined within this model. # Example: from .your_node_module import SourceNode, AssetNode, FileNode # For now, we assume they are available in the scope. for rule in source_rules: source_path = rule.input_path # Use input_path as per SourceRule definition # --- Find the corresponding SourceRule in the model's internal list --- # This replaces the placeholder _find_source_node_by_path logic # We need the *object* and its *index* in self._source_rules source_rule_obj = None source_rule_row = -1 for i, existing_rule in enumerate(self._source_rules): if existing_rule.input_path == source_path: source_rule_obj = existing_rule source_rule_row = i break if source_rule_obj is None: # --- ADD NEW RULE LOGIC --- log.debug(f"No existing rule found for '{source_path}'. Adding new rule to model.") # Ensure parent references are set within the new rule for asset_rule in rule.assets: asset_rule.parent_source = rule # Set parent to the rule being added for file_rule in asset_rule.files: file_rule.parent_asset = asset_rule # Add to model's internal list and emit signal current_row_count = len(self._source_rules) self.beginInsertRows(QModelIndex(), current_row_count, current_row_count) self._source_rules.append(rule) # Append the new rule self.endInsertRows() continue # Skip the rest of the loop for this rule as it's newly added # --- END ADD NEW RULE LOGIC --- # Get the QModelIndex corresponding to the source_rule_obj # This index represents the parent for layout changes. source_index = self.createIndex(source_rule_row, 0, source_rule_obj) if not source_index.isValid(): print(f"Warning: Could not create valid QModelIndex for SourceRule: {source_path}. Skipping update.") continue # --- Signal layout change for the specific source node --- # We are changing the children (AssetRules) of this SourceRule. # Emit with parent index list and orientation. self.layoutAboutToBeChanged.emit() # Emit without arguments # --- Clear existing children (AssetRules) --- # Directly modify the assets list of the found SourceRule object source_rule_obj.assets.clear() # Clear the list in place # --- Rebuild children based on the new rule --- for asset_rule in rule.assets: # Add the new AssetRule object directly source_rule_obj.assets.append(asset_rule) # Set the parent reference on the new asset rule asset_rule.parent_source = source_rule_obj # Set parent references for the FileRules within the new AssetRule for file_rule in asset_rule.files: file_rule.parent_asset = asset_rule # --- Signal layout change completion --- self.layoutChanged.emit() # Emit without arguments print(f"UnifiedViewModel: Updated children for SourceRule: {source_path}") # --- Placeholder for node finding method (Original Request - Replaced by direct list search above) --- # Kept for reference, but the logic above directly searches self._source_rules # def _find_source_node_by_path(self, path: str) -> 'SourceRule | None': # """Placeholder: Finds a top-level SourceRule by its input_path.""" # # This assumes the model uses separate node objects, which it doesn't. # # The current implementation uses the Rule objects directly. # for i, rule in enumerate(self._source_rules): # if rule.input_path == path: # return rule # Return the SourceRule object itself # return None