diff --git a/README.md b/README.md index 5bedd94..f7956d9 100644 --- a/README.md +++ b/README.md @@ -1 +1,80 @@ -# 3decision_plugin_maestro \ No newline at end of file +# 3decision Maestro Plugin + +A Maestro plugin to search and load structures from the 3decision database. + +## Features + +- **Search**: Search for structures by keyword, PDB code, target name, or other criteria +- **Column Filters**: Filter results by any column (supports numeric filters like `<2.0` for resolution) +- **Projects**: Browse and load structures from your 3decision projects +- **Transformation Matrices**: Structures from projects are loaded with their alignment transformations +- **Associated Files**: View and open associated files (CCP4, MTZ, PDB, CIF, etc.) +- **Metadata**: Structure properties are preserved in the Maestro Project Table + +## Installation + +1. In Maestro, go to **Scripts > Install Custom Scripts...** + +2. Navigate to the `3decision_plugin_maestro` folder and select `threedecision_maestro.py` + +3. Click **Install** + +4. Restart Maestro + +5. The plugin appears in **Scripts > 3decision** + +**Note:** The installer automatically copies both `threedecision_maestro.py` and the `threedecision_maestro_dir/` folder containing the plugin files. + +## Configuration + +1. Open the plugin from **Scripts > 3decision** +2. Click **Settings** +3. Enter your 3decision API URL (e.g., `https://your-instance.discngine.cloud`) +4. Enter your API Key +5. Click **Test Connection** to verify +6. Click **Save** + +Configuration is saved to `~/.3decision_maestro_config`. + +## Usage + +### Search Tab +- Enter a search term and click **Submit** +- Use the filter fields below the search to narrow results +- Resolution filter supports: `<2.0`, `>1.5`, `1.5-3.0`, `<=2.0` +- Select structures and click **Load Selected in Maestro** + +### Projects Tab +- Click **Refresh Projects** to load your projects +- Click a project to see its structures +- Use filters to narrow down structures +- Click **View** to see associated files for a structure +- Select structures and click **Load Selected Structures** + +### Associated Files Tab +- Shows files for the selected structure (CCP4 maps, MTZ, etc.) +- **Open in Maestro**: Import structure/map files directly +- **Download & Open**: Open with system default application + +## Loaded Structure Properties + +Structures imported into Maestro include these properties: +- `s_user_3decision_structure_id` +- `s_user_3decision_external_code` +- `s_user_3decision_label` +- `s_user_3decision_source` + +## Requirements + +- Maestro 2020-1 or later +- Network access to your 3decision server + +## Troubleshooting + +**Plugin doesn't appear in Scripts menu:** +- Restart Maestro after installation + +**Connection failed:** +- Verify your API URL includes `https://` +- Check that your API key is valid +- Check network/proxy settings diff --git a/threedecision_maestro.py b/threedecision_maestro.py new file mode 100644 index 0000000..c0411da --- /dev/null +++ b/threedecision_maestro.py @@ -0,0 +1,66 @@ +#Name: Discngine 3decision +#Command: pythonrun threedecision_maestro.run_plugin +""" +3decision Maestro Plugin + +Search and load structures from 3decision database into Maestro. +""" + +__version__ = "1.0" +_panel = None + + +def run_plugin(): + """Launch the 3decision plugin panel.""" + global _panel + import sys + import os + + # The plugin files are in threedecision_maestro_dir/ next to this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + plugin_dir = os.path.join(script_dir, "threedecision_maestro_dir") + + # Fallback: check if files are in the same directory (development mode) + if not os.path.exists(os.path.join(plugin_dir, "gui.py")): + plugin_dir = script_dir + + if plugin_dir not in sys.path: + sys.path.insert(0, plugin_dir) + + try: + from gui import ThreeDecisionPanel + _panel = ThreeDecisionPanel.panel(run=True) + return _panel + except ImportError as e: + msg = ( + f"Failed to load 3decision plugin: {e}\n\n" + "The plugin folder must contain: gui.py, api_client.py\n\n" + f"Current location: {plugin_dir}" + ) + print(f"ERROR: {msg}") + try: + from schrodinger.Qt.QtWidgets import QMessageBox + QMessageBox.critical(None, "3decision Plugin Error", msg) + except ImportError: + # QMessageBox not available (running outside Maestro) + pass + except Exception as ui_error: + # Log unexpected errors without crashing + print(f"WARNING: Could not display error dialog: {ui_error}") + return None + + +def open_panel(): + """ + Alternative entry point for opening the panel. + + This can be used from the Maestro Python console: + import threedecision_maestro + threedecision_maestro.open_panel() + """ + return run_plugin() + + +if __name__ == "__main__": + # Allow running the script directly for testing + run_plugin() diff --git a/threedecision_maestro_dir/__init__.py b/threedecision_maestro_dir/__init__.py new file mode 100644 index 0000000..8d9ce90 --- /dev/null +++ b/threedecision_maestro_dir/__init__.py @@ -0,0 +1,19 @@ +""" +3decision Maestro Plugin v1.0 + +A plugin to search and load structures from 3decision database into Maestro. +""" +#Name: 3decision +#Command: pythonrun threedecision_maestro.run_plugin + +# Plugin version +__version__ = "1.0" + +def run_plugin(): + """ + Launch the 3decision plugin panel. + This is the entry point called by Maestro's pythonrun command. + """ + from .gui import ThreeDecisionPanel + panel = ThreeDecisionPanel.panel(run=True) + return panel diff --git a/threedecision_maestro_dir/api_client.py b/threedecision_maestro_dir/api_client.py new file mode 100644 index 0000000..bc7669d --- /dev/null +++ b/threedecision_maestro_dir/api_client.py @@ -0,0 +1,1311 @@ +""" +3decision API Client v1.0 + +Handles all communication with the 3decision API endpoints. + +Version: 1.0 +""" + +import json +import requests +from typing import Optional, Dict, List, Any +import os +import configparser +import urllib3 + +# Disable SSL warnings when ignoring certificate verification +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +# Simple logging state - stored as module-level variable +_logging_enabled = False + +# SSL verification setting - enabled by default for security +_ssl_verify_enabled = True + +# Private structure naming attribute - stored as module-level variable +# Options: 'label', 'title', 'external_code', 'internal_id' +_private_structure_naming_attribute = 'label' + +def set_logging_enabled(enabled): + """Enable or disable logging output""" + global _logging_enabled + _logging_enabled = enabled + +def is_logging_enabled(): + """Check if logging is enabled""" + global _logging_enabled + return _logging_enabled + +def set_ssl_verify_enabled(enabled): + """Enable or disable SSL certificate verification. + + SSL verification is enabled by default for security. + Only disable for development/internal servers with self-signed certificates. + """ + global _ssl_verify_enabled + _ssl_verify_enabled = enabled + +def is_ssl_verify_enabled(): + """Check if SSL verification is enabled""" + global _ssl_verify_enabled + return _ssl_verify_enabled + +def set_private_structure_naming_attribute(attribute): + """Set the attribute to use for naming private structures. + + Args: + attribute: One of 'label', 'title', 'external_code', or 'internal_id' + """ + global _private_structure_naming_attribute + if attribute in ('label', 'title', 'external_code', 'internal_id'): + _private_structure_naming_attribute = attribute + else: + _private_structure_naming_attribute = 'label' # Default + +def get_private_structure_naming_attribute(): + """Get the attribute used for naming private structures. + + Returns: + One of 'label', 'title', 'external_code', or 'internal_id' + """ + global _private_structure_naming_attribute + return _private_structure_naming_attribute + +def log_debug(message): + """Log a debug message if logging is enabled""" + if _logging_enabled: + print(f"DEBUG: {message}") + +def log_error(message): + """Log an error message if logging is enabled""" + if _logging_enabled: + print(f"ERROR: {message}") + + +class ThreeDecisionAPIClient: + """ + Client for interacting with 3decision API + + Note: SSL certificate verification is disabled for all requests + to support local development servers and internal APIs. + """ + + def __init__(self): + self.base_url = None + self.api_key = None + self.token = None + self.session = requests.Session() + self.config_file = os.path.expanduser("~/.3decision_maestro_config") + self.load_config() + # Apply SSL verification setting (loaded from config) + self.session.verify = is_ssl_verify_enabled() + + def load_config(self): + """Load configuration from file""" + if os.path.exists(self.config_file): + try: + config = configparser.ConfigParser() + config.read(self.config_file) + + if 'API' in config: + self.base_url = config['API'].get('base_url') + self.api_key = config['API'].get('api_key') + self.token = config['API'].get('token') + + # Load logging setting + log_enabled_str = config['API'].get('logging_enabled', 'false') + log_enabled = log_enabled_str.lower() == 'true' + set_logging_enabled(log_enabled) + + # Load SSL verification setting (defaults to true for security) + ssl_verify_str = config['API'].get('ssl_verify', 'true') + ssl_verify = ssl_verify_str.lower() == 'true' + set_ssl_verify_enabled(ssl_verify) + + # Load private structure naming attribute setting + naming_attr = config['API'].get('private_structure_naming_attribute', 'label') + set_private_structure_naming_attribute(naming_attr) + + if self.token: + self.session.headers.update({ + 'Authorization': f'Bearer {self.token}', + 'X-API-Version': '1' + }) + + except Exception as e: + log_error(f"Error loading config: {e}") + + def save_config(self): + """Save configuration to file""" + try: + config = configparser.ConfigParser() + config['API'] = { + 'base_url': self.base_url or '', + 'api_key': self.api_key or '', + 'token': self.token or '', + 'logging_enabled': str(is_logging_enabled()).lower(), + 'ssl_verify': str(is_ssl_verify_enabled()).lower(), + 'private_structure_naming_attribute': get_private_structure_naming_attribute() + } + + with open(self.config_file, 'w') as f: + config.write(f) + + # Set restrictive permissions (owner read/write only) for security + import stat + os.chmod(self.config_file, stat.S_IRUSR | stat.S_IWUSR) + + except Exception as e: + log_error(f"Error saving config: {e}") + + def _request_with_retry(self, method: str, url: str, headers: Dict = None, + json_data: Dict = None, params: Dict = None, + description: str = "request") -> Optional[requests.Response]: + """ + Make an HTTP request with automatic retry on authentication failure. + + If the request returns 401/403, attempts to re-login and retry once. + """ + # Ensure headers exist and have Authorization + if headers is None: + headers = {} + if 'Authorization' not in headers and self.token: + headers['Authorization'] = f'Bearer {self.token}' + + # Make initial request + log_debug(f"{description} URL: {url}") + log_debug(f"Request method: {method}") + + if method.upper() == 'GET': + response = self.session.get(url, headers=headers, params=params) + elif method.upper() == 'POST': + response = self.session.post(url, headers=headers, json=json_data, params=params) + else: + log_error(f"Unsupported HTTP method: {method}") + return None + + log_debug(f"Response status: {response.status_code}") + + # If authentication failed, try to re-login and retry + if response.status_code in [401, 403]: + log_debug(f"Authentication failed for {description}, attempting re-login...") + + # Clear the old token + self.token = None + if 'Authorization' in self.session.headers: + del self.session.headers['Authorization'] + + if self.login(): + log_debug(f"Re-login successful, retrying {description}") + # Update headers with new token + headers['Authorization'] = f'Bearer {self.token}' + + if method.upper() == 'GET': + response = self.session.get(url, headers=headers, params=params) + else: + response = self.session.post(url, headers=headers, json=json_data, params=params) + + log_debug(f"Retry response status: {response.status_code}") + else: + log_error(f"Re-login failed for {description}") + return None + + return response + + def save_logging_setting(self, enabled: bool): + """Save logging setting to config file""" + set_logging_enabled(enabled) + self.save_config() + + def save_naming_attribute_setting(self, attribute: str): + """Save private structure naming attribute setting to config file""" + set_private_structure_naming_attribute(attribute) + self.save_config() + + def save_ssl_setting(self, enabled: bool): + """Save SSL verification setting to config file""" + set_ssl_verify_enabled(enabled) + self.session.verify = enabled + self.save_config() + + def configure(self, base_url: str, api_key: str): + """Configure API settings""" + # Ensure the URL includes the scheme + if not base_url.startswith(('http://', 'https://')): + base_url = 'http://' + base_url + + self.base_url = base_url.rstrip('/') + self.api_key = api_key + self.token = None + + # Update session headers + self.session.headers.update({ + 'Dng-Api-Key': self.api_key, + 'X-API-Version': '1', + 'User-Agent': 'Maestro-3decision-Plugin/1.0' + }) + + # Remove Authorization header if present + if 'Authorization' in self.session.headers: + del self.session.headers['Authorization'] + + def is_configured(self) -> bool: + """Check if API is configured""" + return bool(self.base_url and self.api_key) + + def is_authenticated(self) -> bool: + """Check if user is authenticated (configured and connection works)""" + return self.is_configured() and self.test_connection() + + def login(self) -> bool: + """Login and get authentication token""" + if not self.is_configured(): + return False + + try: + url = f"{self.base_url}/auth/api/login" + log_debug(f"Attempting login to: {url}") + + # Ensure we have the API key header for login + headers = { + 'Dng-Api-Key': self.api_key, + 'X-API-Version': '1', + 'User-Agent': 'Maestro-3decision-Plugin/1.0' + } + + response = self.session.get(url, headers=headers) + log_debug(f"Response status: {response.status_code}") + + if response.status_code == 200 or response.status_code == 201: + data = response.json() + self.token = data.get('access_token') + + if self.token: + # Update session with token + self.session.headers.update({ + 'Authorization': f'Bearer {self.token}', + 'X-API-Version': '1', + 'User-Agent': 'Maestro-3decision-Plugin/1.0' + }) + # Remove API key header as we now have token + if 'Dng-Api-Key' in self.session.headers: + del self.session.headers['Dng-Api-Key'] + + self.save_config() + log_debug("Login successful") + return True + + log_error(f"Login failed: {response.status_code} - {response.text}") + return False + + except Exception as e: + log_error(f"Login error: {e}") + return False + + def test_connection(self) -> bool: + """Test API connection. + + If we have a token, we trust it's valid. The _request_with_retry method + will handle re-authentication if the token expires during actual API calls. + If no token exists, we attempt to login. + """ + if not self.is_configured(): + return False + + # If we have a token, trust it - _request_with_retry handles re-auth if needed + if self.token and 'Authorization' in self.session.headers: + log_debug("Using existing token for connection") + return True + + # No token - try to login + log_debug("No token found, attempting login") + return self.login() + + def submit_search(self, query: str) -> Optional[Dict[str, Any]]: + """Submit a search query and return job info""" + if not self.test_connection(): + return None + + try: + # Step 1: Submit search to get job ID + url = f"{self.base_url}/search/{query}" + + response = self._request_with_retry( + method='GET', + url=url, + description="Search submission" + ) + + if response is None: + return None + + if response.status_code in [200, 201]: + try: + data = response.json() + log_debug(f"Search response data: {data}") + + # Extract job ID from response + job_id = data.get('id') + if job_id: + log_debug(f"Extracted job ID: {job_id}") + + # Step 2: Poll the queue endpoint to get results + queue_url = f"{self.base_url}/queues/basicSearch/jobs/{job_id}" + + queue_response = self._request_with_retry( + method='GET', + url=queue_url, + description="Queue polling" + ) + + if queue_response is None: + return None + + if queue_response.status_code in [200, 201]: + queue_data = queue_response.json() + progress = queue_data.get("progress") + if progress is None: + progress = 0 + log_debug(f"queue data: {queue_data}") + if progress == 100: + log_debug("Job completed (progress = 100%), extracting results") + + # Extract structure IDs from returnvalue.STRUCTURE_ID + structure_ids = [] + if 'returnvalue' in queue_data and 'STRUCTURE_ID' in queue_data['returnvalue']: + structure_ids = queue_data['returnvalue']['STRUCTURE_ID'] + log_debug(f"Extracted structure IDs: {structure_ids}") + + # If we have structure IDs, fetch detailed info via GraphQL + if structure_ids: + structures_info = self.get_structures_info(structure_ids) + log_debug(f"Retrieved {len(structures_info)} structure details") + + # Return the complete job data with structure details + queue_data['structures_info'] = structures_info + return queue_data + else: + log_debug("No structure IDs found in queue response - job completed with no results") + queue_data['structures_info'] = [] + return queue_data + else: + log_debug(f"Job still in progress ({progress}%), need to poll again") + return { + 'id': job_id, + 'queue': 'basicSearch', + 'status': 'running', + 'progress': progress, + 'polling_needed': True + } + else: + log_error(f"Queue polling failed: {queue_response.status_code} - {queue_response.text}") + return None + else: + log_debug("No job ID found in search response") + return { + 'id': 'direct', + 'queue': 'direct', + 'status': 'completed', + 'result': data + } + + except json.JSONDecodeError: + log_error(f"Non-JSON response from search: {response.text}") + return None + + else: + log_error(f"Search submission failed: {response.status_code}") + log_error(f"Response text: {response.text}") + return None + + except Exception as e: + log_error(f"Search submission error: {e}") + import traceback + log_error(f"Full traceback: {traceback.format_exc()}") + return None + + def get_job_status(self, queue_name: str, job_id: int) -> Optional[Dict[str, Any]]: + """Get status of a job""" + if not self.test_connection(): + return None + + try: + url = f"{self.base_url}/queues/{queue_name}/jobs/{job_id}" + + response = self._request_with_retry( + method='GET', + url=url, + description="Job status check" + ) + + if response is None: + return None + + if response.status_code in [200, 201]: + data = response.json() + log_debug(f"Job status response data: {data}") + return data + else: + log_error(f"Job status check failed: {response.status_code}") + return None + + except Exception as e: + log_error(f"Job status error: {e}") + return None + + def get_structures_info(self, structure_ids: List[int]) -> List[Dict[str, Any]]: + """Get detailed information for structures using GraphQL""" + if not self.test_connection(): + return [] + + try: + # If we have more than 500 structures, split into batches + batch_size = 500 + all_structures = [] + + if len(structure_ids) > batch_size: + log_debug(f"Large dataset detected ({len(structure_ids)} structures). Splitting into batches of {batch_size}...") + + for i in range(0, len(structure_ids), batch_size): + batch_ids = structure_ids[i:i + batch_size] + batch_num = (i // batch_size) + 1 + total_batches = (len(structure_ids) + batch_size - 1) // batch_size + + log_debug(f"Processing batch {batch_num}/{total_batches} ({len(batch_ids)} structures)...") + + batch_structures = self._fetch_structures_batch(batch_ids) + if batch_structures: + all_structures.extend(batch_structures) + log_debug(f"Batch {batch_num} completed: {len(batch_structures)} structures retrieved") + + log_debug(f"All batches completed. Total structures retrieved: {len(all_structures)}") + return all_structures + else: + log_debug(f"Small dataset ({len(structure_ids)} structures). Using single request...") + return self._fetch_structures_batch(structure_ids) + + except Exception as e: + log_error(f"Structures info error: {e}") + return [] + + def _fetch_structures_batch(self, structure_ids: List[int]) -> List[Dict[str, Any]]: + """Fetch a batch of structures using GraphQL""" + try: + query = """ + query GetStructuresInfo($ids: [Int!]!) { + getStructuresInfo(ids: $ids) { + structure_id + general { + structure_id + external_code + title + method + resolution + created_date + imported_date + created_by + imported_by + source + } + } + } + """ + + variables = {"ids": structure_ids} + + url = f"{self.base_url}/graphql" + payload = { + "query": query, + "variables": variables + } + + log_debug(f"GraphQL batch request (batch size: {len(structure_ids)})") + + response = self._request_with_retry( + method='POST', + url=url, + json_data=payload, + description="GraphQL structures batch" + ) + + if response is None: + return [] + + if response.status_code in [200, 201]: + data = response.json() + + if 'data' in data and 'getStructuresInfo' in data['data']: + batch_results = data['data']['getStructuresInfo'] + + # Remove duplicates based on structure_id (NMR structures with multiple states) + seen_ids = set() + unique_structures = [] + for structure in batch_results: + structure_id = structure.get('structure_id') + if structure_id and structure_id not in seen_ids: + seen_ids.add(structure_id) + unique_structures.append(structure) + + return unique_structures + else: + log_error(f"Unexpected GraphQL response structure") + return [] + else: + log_error(f"GraphQL batch query failed: {response.status_code}") + return [] + + except Exception as e: + log_error(f"Batch fetch error: {e}") + return [] + + def get_structure_internal_id(self, structure_id: int) -> Optional[str]: + """ + Fetch the internal_id annotation for a structure. + """ + if not self.test_connection(): + return None + + try: + url = f"{self.base_url}/structures/info/annotation" + params = { + "structure_id": [int(structure_id)] + } + headers = { + 'X-API-VERSION': '1' + } + + log_debug(f"Fetching internal_id for structure_id: {structure_id}") + + response = self._request_with_retry( + method='GET', + url=url, + headers=headers, + params=params, + description=f"Structure annotations for structure_id {structure_id}" + ) + + if response is None: + return None + + if response.status_code in [200, 201]: + data = response.json() + + if isinstance(data, list) and len(data) > 0: + structure_data = data[0] + annotation_info = structure_data.get('ANNOTATION_INFO', {}) + structure_annots = annotation_info.get('StructureAnnot', []) + + for annot in structure_annots: + if annot.get('ANNOT_TYPE_LABEL', '').lower() == 'internal id': + internal_id = annot.get('ANNOT_VALUE') + if internal_id: + log_debug(f"Found internal_id for structure_id {structure_id}: {internal_id}") + return internal_id + + log_debug(f"No internal_id annotation found for structure_id {structure_id}") + return None + else: + log_error(f"Structure annotations request failed: {response.status_code}") + return None + + except Exception as e: + log_error(f"Get internal_id error: {e}") + return None + + def export_structure_pdb(self, structure_id: str) -> Optional[str]: + """Export structure in PDB format""" + if not self.test_connection(): + return None + + try: + url = f"{self.base_url}/exports/structure" + params = {"output_format": "structures-pdb-txt"} + + payload = { + "structures_id": [int(structure_id)] + } + + log_debug(f"Export request params: {params}") + + response = self._request_with_retry( + method='POST', + url=url, + json_data=payload, + params=params, + description="Structure export" + ) + + if response is None: + return None + + if response.status_code in [200, 201]: + domain_event_id = response.text.strip() + log_debug(f"Received domain event ID: {domain_event_id}") + + if domain_event_id: + domain_events_url = f"{self.base_url}/domain-events/{domain_event_id}" + log_debug(f"Polling domain events endpoint: {domain_events_url}") + + max_attempts = 30 + attempt = 0 + + while attempt < max_attempts: + log_debug(f"Domain events poll attempt {attempt + 1}/{max_attempts}") + + domain_response = self._request_with_retry( + method='GET', + url=domain_events_url, + description="Export domain events polling" + ) + + if domain_response is None: + return None + + if domain_response.status_code in [200, 201]: + try: + domain_data = domain_response.json() + state = domain_data.get('state') + log_debug(f"Export state: {state}") + + if state == 'success': + content = domain_data.get('content', {}) + errors = content.get('errors', {}) + not_exported = errors.get('not_exported', []) + + if not_exported: + log_error(f"Export completed with errors: {not_exported}") + return None + else: + log_debug("Export completed successfully") + + file_names = content.get('file_names', []) + if file_names and len(file_names) > 0: + first_file = file_names[0] + if isinstance(first_file, dict): + filename = first_file.get("file_name") + else: + log_error(f"Unexpected file_names structure") + return None + + if not filename: + log_error("No filename found in file_names entry") + return None + + filename_without_ext = filename + if '.' in filename: + filename_without_ext = filename.rsplit('.', 1)[0] + + download_url = f"{self.base_url}/exports/structure/{domain_event_id}?filename={filename_without_ext}&download=true" + + download_response = self._request_with_retry( + method='GET', + url=download_url, + description="PDB file download" + ) + + if download_response is None: + return None + + if download_response.status_code in [200, 201]: + pdb_content = download_response.text + log_debug(f"Download successful, PDB content length: {len(pdb_content)} characters") + return pdb_content + else: + log_error(f"Download failed: {download_response.status_code}") + return None + else: + log_error("No filename found in export response") + return None + + elif state == 'failed': + log_error("Export failed") + return None + else: + log_debug(f"Export still processing (state: {state}), waiting...") + import time + time.sleep(2) + attempt += 1 + continue + + except json.JSONDecodeError as e: + log_error(f"Failed to parse domain events response: {e}") + return None + else: + log_error(f"Domain events request failed: {domain_response.status_code}") + return None + + log_error("Export polling timed out") + return None + else: + log_error("No domain event ID received") + return None + else: + log_error(f"Export request failed: {response.status_code}") + return None + + except Exception as e: + log_error(f"Structure export error: {e}") + import traceback + log_error(f"Full traceback: {traceback.format_exc()}") + return None + + def get_projects(self) -> List[Dict[str, Any]]: + """Get list of available projects""" + if not self.is_configured(): + raise Exception("API not configured") + + if not self.test_connection(): + raise Exception("Not authenticated") + + try: + url = f"{self.base_url}/projects" + headers = { + "Accept": "application/json" + } + + response = self._request_with_retry( + method='GET', + url=url, + headers=headers, + description="Projects request" + ) + + if response is None: + raise Exception("Authentication failed - please check your API key in Settings") + + if response.status_code == 200: + projects_data = response.json() + log_debug(f"Projects response: {projects_data}") + + if isinstance(projects_data, list): + return projects_data + elif isinstance(projects_data, dict): + if 'projects' in projects_data: + return projects_data['projects'] + elif 'results' in projects_data: + return projects_data['results'] + else: + log_error(f"Unexpected projects response format: {projects_data}") + return [] + else: + log_error(f"Unexpected projects response format: {projects_data}") + return [] + + else: + log_error(f"Projects request failed: {response.status_code}") + raise Exception(f"Projects request failed: {response.status_code}") + + except Exception as e: + log_error(f"Projects request error: {e}") + raise + + def get_project_structures(self, project_id: str) -> List[Dict[str, Any]]: + """Get structures from a specific project with transformation matrices""" + if not self.is_configured(): + raise Exception("API not configured") + + if not self.test_connection(): + raise Exception("Not authenticated") + + try: + url = f"{self.base_url}/projects/{project_id}/structures/matrix" + headers = { + "Accept": "application/json" + } + + response = self._request_with_retry( + method='GET', + url=url, + headers=headers, + description="Project structures request" + ) + + if response is None: + raise Exception("Authentication failed - please check your API key in Settings") + + if response.status_code == 200: + structures_data = response.json() + log_debug(f"Project structures response: {structures_data}") + + if isinstance(structures_data, list): + return structures_data + elif isinstance(structures_data, dict) and 'results' in structures_data: + return structures_data['results'] + else: + log_error(f"Unexpected project structures response format: {structures_data}") + return [] + + else: + log_error(f"Project structures request failed: {response.status_code}") + raise Exception(f"Project structures request failed: {response.status_code}") + + except Exception as e: + log_error(f"Project structures request error: {e}") + raise + + def export_structures_with_transforms(self, structures_with_transforms: List[Dict]) -> Optional[Dict[str, str]]: + """Export multiple structures with transformation matrices using batch export""" + if not self.test_connection(): + return None + + try: + if len(structures_with_transforms) > 1: + log_debug(f"Exporting {len(structures_with_transforms)} structures with transformations as ZIP") + + matrix_payload = [ + { + "external_code": item["external_code"], + "structure_id": item["structure_id"], + "transform": item["transform"] + } + for item in structures_with_transforms + ] + + structure_ids = [item["structure_id"] for item in structures_with_transforms] + + zip_content = self.download_structures_zip(structure_ids, matrix_payload) + + if not zip_content: + log_error("Failed to download structures as ZIP") + return None + + import zipfile + import io + + pdb_files_dict = {} + + try: + with zipfile.ZipFile(io.BytesIO(zip_content), 'r') as zip_ref: + file_list = zip_ref.namelist() + log_debug(f"ZIP contains {len(file_list)} files: {file_list}") + + pdb_files = [f for f in file_list if f.endswith('.pdb')] + log_debug(f"Found {len(pdb_files)} PDB files in ZIP") + + for pdb_filename in pdb_files: + log_debug(f"Extracting {pdb_filename} from ZIP") + pdb_content = zip_ref.read(pdb_filename).decode('utf-8') + base_filename = pdb_filename.split('/')[-1] + pdb_files_dict[base_filename] = pdb_content + + if pdb_files_dict: + log_debug(f"Successfully extracted {len(pdb_files_dict)} structures from ZIP") + return pdb_files_dict + else: + log_error("No PDB files were extracted from ZIP") + return None + + except zipfile.BadZipFile as e: + log_error(f"Invalid ZIP file: {e}") + return None + except Exception as e: + log_error(f"Error extracting PDB files from ZIP: {e}") + return None + + else: + # Single structure + url = f"{self.base_url}/exports/structure" + + matrix_payload = { + "external_codes": [item["external_code"] for item in structures_with_transforms], + "matrix": [ + { + "external_code": item["external_code"], + "structure_id": item["structure_id"], + "transform": item["transform"] + } + for item in structures_with_transforms + ] + } + + params = { + "output_format": "structures-pdb-txt" + } + + log_debug(f"Batch export payload: {json.dumps(matrix_payload, indent=2)}") + + response = self._request_with_retry( + method='POST', + url=url, + json_data=matrix_payload, + params=params, + description="Batch export (single structure)" + ) + + if response is None: + return None + + if response.status_code in [200, 201]: + domain_event_id = response.text.strip() + log_debug(f"Received domain event ID: {domain_event_id}") + + if domain_event_id: + domain_events_url = f"{self.base_url}/domain-events/{domain_event_id}" + + max_attempts = 30 + attempt = 0 + + while attempt < max_attempts: + domain_response = self._request_with_retry( + method='GET', + url=domain_events_url, + description="Batch export domain events polling" + ) + + if domain_response is None: + return None + + if domain_response.status_code in [200, 201]: + try: + domain_data = domain_response.json() + state = domain_data.get('state') + + if state == 'success': + content = domain_data.get('content', {}) + errors = content.get('errors', {}) + not_exported = errors.get('not_exported', []) + + if not_exported: + log_error(f"Batch export completed with errors: {not_exported}") + return None + + log_debug("Batch export completed successfully") + + file_names = content.get('file_names', []) + if file_names and len(file_names) > 0: + first_file = file_names[0] + if isinstance(first_file, dict): + filename = first_file.get("file_name") + else: + log_error(f"Unexpected file_names structure: {first_file}") + return None + + if not filename: + log_error("No filename found in file_names entry") + return None + + filename_without_ext = filename + if '.' in filename: + filename_without_ext = filename.rsplit('.', 1)[0] + + download_url = f"{self.base_url}/exports/structure/{domain_event_id}?filename={filename_without_ext}&download=true" + + download_response = self._request_with_retry( + method='GET', + url=download_url, + description="Batch export PDB download" + ) + + if download_response is None: + return None + + if download_response.status_code == 200: + pdb_content = download_response.text + log_debug(f"Successfully downloaded PDB content: {len(pdb_content)} bytes") + external_code = structures_with_transforms[0]["external_code"] + return {f"{external_code}.pdb": pdb_content} + else: + log_error(f"Failed to download PDB: {download_response.status_code}") + return None + else: + log_error("No file_names found in export response") + return None + + elif state == 'failed': + log_error("Batch export failed") + return None + + except Exception as e: + log_error(f"Error parsing domain event response: {e}") + + attempt += 1 + if attempt < max_attempts: + import time + time.sleep(2) + + log_error("Batch export polling timed out") + return None + else: + log_error("No domain event ID received") + return None + else: + log_error(f"Batch export request failed: {response.status_code}") + return None + + except Exception as e: + log_error(f"Batch export error: {e}") + import traceback + log_error(f"Full traceback: {traceback.format_exc()}") + return None + + def download_structures_zip(self, structure_ids: List[int], matrices: List[Dict] = None) -> Optional[bytes]: + """Download structures as a ZIP file""" + if not self.is_authenticated(): + raise Exception("Not authenticated") + + try: + url = f"{self.base_url}/exports/structure" + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json" + } + + payload = { + "structures_id": structure_ids + } + + if matrices: + payload["matrix"] = matrices + + params = { + "output_format": "structures-pdb-zip" + } + + log_debug(f"Structure IDs: {structure_ids}") + + response = self._request_with_retry( + method='POST', + url=url, + headers=headers, + json_data=payload, + params=params, + description="ZIP export request" + ) + + if response is None: + return None + + if response.status_code in [200, 201]: + domain_event_id = response.text.strip() + log_debug(f"Received domain event ID: {domain_event_id}") + + if domain_event_id: + domain_events_url = f"{self.base_url}/domain-events/{domain_event_id}" + + max_attempts = 30 + attempt = 0 + + while attempt < max_attempts: + domain_response = self._request_with_retry( + method='GET', + url=domain_events_url, + description="ZIP export domain events polling" + ) + + if domain_response is None: + return None + + if domain_response.status_code in [200, 201]: + try: + domain_data = domain_response.json() + state = domain_data.get('state') + + if state == 'success': + content = domain_data.get('content', {}) + errors = content.get('errors', {}) + not_exported = errors.get('not_exported', []) + + if not_exported: + log_error(f"Export completed with errors: {not_exported}") + return None + else: + log_debug("Export completed successfully") + + file_names = content.get('file_names', []) + + if file_names and len(file_names) > 0: + first_file = file_names[0] + + if isinstance(first_file, dict): + filename = first_file.get("file_name") + else: + log_error(f"Unexpected file_names structure") + return None + + if not filename: + log_error("No filename found in file_names entry") + return None + + filename_without_ext = filename + if '.' in filename: + filename_without_ext = filename.rsplit('.', 1)[0] + + download_url = f"{self.base_url}/exports/structure/{domain_event_id}?filename={filename_without_ext}&download=true" + + download_response = self._request_with_retry( + method='GET', + url=download_url, + description="ZIP file download" + ) + + if download_response is None: + return None + + if download_response.status_code in [200, 201]: + zip_content = download_response.content + log_debug(f"Download successful, ZIP content length: {len(zip_content)} bytes") + + if len(zip_content) > 4 and zip_content[:2] == b'PK': + log_debug("Confirmed ZIP file format (starts with PK signature)") + return zip_content + else: + log_debug(f"Warning: Downloaded content may not be a ZIP file") + return zip_content + else: + log_error(f"Download failed: {download_response.status_code}") + return None + else: + log_error("No filename found in export response") + return None + + elif state == 'failed': + log_error("Export failed") + return None + else: + log_debug(f"Export still processing (state: {state}), waiting...") + import time + time.sleep(2) + attempt += 1 + continue + + except json.JSONDecodeError as e: + log_error(f"Failed to parse domain events response: {e}") + return None + else: + log_error(f"Domain events request failed: {domain_response.status_code}") + return None + + log_error("Export polling timed out") + return None + else: + log_error("No domain event ID received") + return None + else: + log_error(f"Export request failed: {response.status_code}") + return None + + except Exception as e: + log_error(f"ZIP download error: {e}") + return None + + def get_associated_files(self, external_code: str) -> List[Dict[str, Any]]: + """Get associated files for a structure by external code""" + try: + if not self.test_connection(): + log_error("Not authenticated for associated files request") + return [] + + url = f"{self.base_url}/structures/{external_code}/associated-files" + headers = { + "Accept": "application/json" + } + + response = self._request_with_retry( + method='GET', + url=url, + headers=headers, + description="Associated files request" + ) + + if response is None: + return [] + + if response.status_code == 200: + files_data = response.json() + log_debug(f"Associated files response: {json.dumps(files_data, indent=2)}") + + if isinstance(files_data, list): + return files_data + elif isinstance(files_data, dict): + if 'files' in files_data: + return files_data['files'] + elif 'results' in files_data: + return files_data['results'] + else: + log_error(f"Unexpected associated files response format: {files_data}") + return [] + else: + log_error(f"Unexpected associated files response format: {files_data}") + return [] + + elif response.status_code == 404: + log_debug("No associated files found (404)") + return [] + else: + log_error(f"Associated files request failed: {response.status_code} - {response.text}") + return [] + + except Exception as e: + log_error(f"Associated files request error: {e}") + return [] + + def download_file_by_id(self, file_id: str) -> Optional[bytes]: + """Download file content by ID""" + try: + if not self.test_connection(): + log_error("Not authenticated for file download") + return None + + url = f"{self.base_url}/structures/file/{file_id}/download" + + response = self._request_with_retry( + method='GET', + url=url, + description="File download" + ) + + if response is None: + return None + + if response.status_code == 200: + log_debug(f"File downloaded successfully: {len(response.content)} bytes") + return response.content + else: + log_error(f"File download failed: {response.status_code} - {response.text}") + return None + + except Exception as e: + log_error(f"File download error: {e}") + return None + + def download_file(self, file_info: Dict[str, Any]) -> Optional[bytes]: + """Download file using file info dictionary""" + file_id = file_info.get('id') or file_info.get('file_id') or file_info.get('FILE_ID') + + if not file_id: + download_url = file_info.get('download_url') or file_info.get('url') + if download_url: + return self._download_from_url(download_url) + else: + log_error("No file ID or download URL found in file info") + return None + + return self.download_file_by_id(str(file_id)) + + def _download_from_url(self, url: str) -> Optional[bytes]: + """Download file from direct URL""" + try: + if not self.test_connection(): + log_error("Not authenticated for URL download") + return None + + response = self._request_with_retry( + method='GET', + url=url, + description="Direct URL download" + ) + + if response is None: + return None + + if response.status_code == 200: + content = response.content + log_debug(f"Direct URL download successful: {len(content)} bytes") + return content + else: + log_error(f"Direct URL download failed: {response.status_code}") + return None + + except Exception as e: + log_error(f"Direct URL download error: {e}") + return None diff --git a/threedecision_maestro_dir/gui.py b/threedecision_maestro_dir/gui.py new file mode 100644 index 0000000..5d819de --- /dev/null +++ b/threedecision_maestro_dir/gui.py @@ -0,0 +1,2614 @@ +""" +3decision Plugin GUI v1.0 + +Main panel and interface components for the 3decision Maestro plugin. + +Version: 1.0 +""" + +import os +import sys +import re +import subprocess +import tempfile +from typing import Dict, List, Any + +# Import Schrodinger modules +try: + from schrodinger.ui.qt.appframework2 import af2 + from schrodinger.Qt import QtGui + from schrodinger.Qt.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, + QTableWidget, QTableWidgetItem, QLabel, QMessageBox, QHeaderView, + QProgressBar, QCheckBox, QAbstractItemView, QTabWidget, QWidget, + QFormLayout, QComboBox, QDialogButtonBox + ) + from schrodinger.Qt.QtCore import Qt, QThread, pyqtSignal + from schrodinger.Qt.QtGui import QPixmap, QIcon + from schrodinger import maestro, structure + MAESTRO_AVAILABLE = True +except ImportError: + # Fallback for testing outside Maestro + from PyQt5 import QtGui + from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, + QTableWidget, QTableWidgetItem, QLabel, QMessageBox, QHeaderView, + QProgressBar, QCheckBox, QAbstractItemView, QTabWidget, QWidget, + QFormLayout, QComboBox, QDialogButtonBox + ) + from PyQt5.QtCore import Qt, QThread, pyqtSignal + from PyQt5.QtGui import QPixmap, QIcon + MAESTRO_AVAILABLE = False + af2 = None + +# Import api_client - try relative import first, then absolute +# Both forms needed: relative works when installed as package, absolute when run directly +try: + from .api_client import ( + ThreeDecisionAPIClient, is_logging_enabled, get_private_structure_naming_attribute, + is_ssl_verify_enabled, set_ssl_verify_enabled + ) +except ImportError: + from api_client import ( + ThreeDecisionAPIClient, is_logging_enabled, get_private_structure_naming_attribute, + is_ssl_verify_enabled, set_ssl_verify_enabled + ) + + +def sanitize_filename(filename: str) -> str: + """ + Sanitize a filename to prevent path traversal attacks. + Removes path separators and parent directory references. + """ + # Get just the basename, removing any directory components + filename = os.path.basename(filename) + # Remove any remaining path traversal patterns + filename = filename.replace('..', '').replace('/', '').replace('\\', '') + # Remove null bytes + filename = filename.replace('\x00', '') + # If filename is empty after sanitization, use a default + if not filename: + filename = 'unnamed_file' + return filename + + +def sanitize_surface_name(name: str) -> str: + """ + Sanitize a surface name for use in Maestro commands. + Only allows alphanumeric characters, underscores, and hyphens. + """ + return "".join(c if c.isalnum() or c in ("_", "-") else "_" for c in name) + + +# Color constants for dark theme +COLOR_DARK_BG = "#444444" +COLOR_DARK_BG_ALT = "#3a3a3a" +COLOR_DARK_BORDER = "#555555" +COLOR_TEXT = "#ECECEC" +COLOR_TEXT_DIM = "#ABABAB" +COLOR_TEXT_DISABLED = "#666666" +COLOR_TEXT_PLACEHOLDER = "#888888" +COLOR_ACCENT = "#60B0DC" +COLOR_BUTTON = "#505050" +COLOR_BUTTON_HOVER = "#585858" +COLOR_BUTTON_PRESSED = "#404040" +COLOR_BUTTON_BORDER = "#606060" +COLOR_HEADER = "#4a4a4a" +COLOR_SUCCESS = "#7FBA7A" +COLOR_ERROR = "#FF6B6B" +COLOR_SCROLLBAR = "#606060" +COLOR_SCROLLBAR_HOVER = "#707070" + + +def create_dark_palette(): + """ + Create a dark QPalette for widgets. + This ensures consistent dark theming across all widgets. + """ + dark_palette = QtGui.QPalette() + dark_color = QtGui.QColor(68, 68, 68) # COLOR_DARK_BG + dark_palette.setColor(QtGui.QPalette.Window, dark_color) + dark_palette.setColor(QtGui.QPalette.Base, QtGui.QColor(58, 58, 58)) # COLOR_DARK_BG_ALT + dark_palette.setColor(QtGui.QPalette.AlternateBase, dark_color) + dark_palette.setColor(QtGui.QPalette.Button, QtGui.QColor(80, 80, 80)) # COLOR_BUTTON + dark_palette.setColor(QtGui.QPalette.WindowText, QtGui.QColor(236, 236, 236)) # COLOR_TEXT + dark_palette.setColor(QtGui.QPalette.Text, QtGui.QColor(236, 236, 236)) + dark_palette.setColor(QtGui.QPalette.ButtonText, QtGui.QColor(236, 236, 236)) + dark_palette.setColor(QtGui.QPalette.Highlight, QtGui.QColor(96, 176, 220)) # COLOR_ACCENT + dark_palette.setColor(QtGui.QPalette.HighlightedText, QtGui.QColor(255, 255, 255)) + dark_palette.setColor(QtGui.QPalette.PlaceholderText, QtGui.QColor(136, 136, 136)) # COLOR_TEXT_PLACEHOLDER + return dark_palette + + +# Dark theme stylesheet matching Maestro's dark UI +DARK_STYLESHEET = f""" + * {{ + background-color: {COLOR_DARK_BG}; + color: {COLOR_TEXT}; + }} + QWidget, QFrame, QDialog {{ + background-color: {COLOR_DARK_BG}; + color: {COLOR_TEXT}; + }} + QLabel {{ + background-color: transparent; + color: {COLOR_TEXT}; + }} + QStatusBar {{ + background-color: {COLOR_DARK_BG_ALT}; + color: {COLOR_TEXT}; + border-top: 1px solid {COLOR_DARK_BORDER}; + }} + QLineEdit {{ + background-color: {COLOR_DARK_BG_ALT}; + color: {COLOR_TEXT}; + border: 1px solid {COLOR_DARK_BORDER}; + border-radius: 3px; + padding: 4px 6px; + selection-background-color: {COLOR_ACCENT}; + }} + QLineEdit:focus {{ + border: 1px solid {COLOR_ACCENT}; + }} + QLineEdit:disabled {{ + background-color: #383838; + color: {COLOR_TEXT_PLACEHOLDER}; + }} + QLineEdit::placeholder {{ + color: {COLOR_TEXT_PLACEHOLDER}; + }} + QComboBox {{ + background-color: {COLOR_DARK_BG_ALT}; + color: {COLOR_TEXT}; + border: 1px solid {COLOR_DARK_BORDER}; + border-radius: 3px; + padding: 4px 6px; + }} + QComboBox:hover, QComboBox:focus {{ + border: 1px solid {COLOR_ACCENT}; + }} + QComboBox::drop-down {{ + border: none; + width: 20px; + }} + QComboBox::down-arrow {{ + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 6px solid {COLOR_TEXT}; + margin-right: 6px; + }} + QComboBox QAbstractItemView {{ + background-color: {COLOR_DARK_BG_ALT}; + color: {COLOR_TEXT}; + selection-background-color: {COLOR_ACCENT}; + border: 1px solid {COLOR_DARK_BORDER}; + }} + QPushButton {{ + background-color: {COLOR_BUTTON}; + color: {COLOR_TEXT}; + border: 1px solid {COLOR_BUTTON_BORDER}; + border-radius: 3px; + padding: 5px 12px; + min-height: 20px; + }} + QPushButton:hover {{ + background-color: {COLOR_BUTTON_HOVER}; + border: 1px solid {COLOR_ACCENT}; + }} + QPushButton:pressed {{ + background-color: {COLOR_BUTTON_PRESSED}; + }} + QPushButton:disabled {{ + background-color: {COLOR_DARK_BG_ALT}; + color: {COLOR_TEXT_DISABLED}; + border: 1px solid {COLOR_HEADER}; + }} + QCheckBox {{ + background-color: transparent; + color: {COLOR_TEXT}; + spacing: 6px; + }} + QCheckBox::indicator {{ + width: 16px; + height: 16px; + border: 1px solid {COLOR_DARK_BORDER}; + border-radius: 2px; + background-color: {COLOR_DARK_BG_ALT}; + }} + QCheckBox::indicator:checked {{ + background-color: {COLOR_ACCENT}; + border-color: {COLOR_ACCENT}; + }} + QCheckBox::indicator:hover {{ + border-color: {COLOR_ACCENT}; + }} + QTabWidget::pane {{ + border: 1px solid {COLOR_DARK_BORDER}; + background-color: {COLOR_DARK_BG}; + border-radius: 3px; + }} + QTabBar::tab {{ + background-color: {COLOR_DARK_BG_ALT}; + color: {COLOR_TEXT_DIM}; + border: 1px solid {COLOR_DARK_BORDER}; + border-bottom: none; + padding: 6px 16px; + margin-right: 2px; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + }} + QTabBar::tab:selected {{ + background-color: {COLOR_DARK_BG}; + color: {COLOR_TEXT}; + border-bottom: 1px solid {COLOR_DARK_BG}; + }} + QTabBar::tab:hover:!selected {{ + background-color: {COLOR_BUTTON_PRESSED}; + color: {COLOR_TEXT}; + }} + QTableWidget {{ + background-color: {COLOR_DARK_BG_ALT}; + color: {COLOR_TEXT}; + gridline-color: {COLOR_DARK_BORDER}; + border: 1px solid {COLOR_DARK_BORDER}; + selection-background-color: {COLOR_ACCENT}; + selection-color: #FFFFFF; + }} + QTableWidget::item {{ + padding: 4px; + }} + QTableWidget::item:selected {{ + background-color: {COLOR_ACCENT}; + color: #FFFFFF; + }} + QHeaderView::section {{ + background-color: {COLOR_HEADER}; + color: {COLOR_TEXT}; + padding: 5px; + border: none; + border-right: 1px solid {COLOR_DARK_BORDER}; + border-bottom: 1px solid {COLOR_DARK_BORDER}; + }} + QHeaderView::section:hover {{ + background-color: {COLOR_DARK_BORDER}; + }} + QProgressBar {{ + background-color: {COLOR_DARK_BG_ALT}; + border: 1px solid {COLOR_DARK_BORDER}; + border-radius: 3px; + text-align: center; + color: {COLOR_TEXT}; + }} + QProgressBar::chunk {{ + background-color: {COLOR_ACCENT}; + border-radius: 2px; + }} + QScrollBar:vertical {{ + background-color: {COLOR_DARK_BG_ALT}; + width: 12px; + border: none; + }} + QScrollBar::handle:vertical {{ + background-color: {COLOR_SCROLLBAR}; + min-height: 20px; + border-radius: 3px; + margin: 2px; + }} + QScrollBar::handle:vertical:hover {{ + background-color: {COLOR_SCROLLBAR_HOVER}; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0px; + }} + QScrollBar:horizontal {{ + background-color: {COLOR_DARK_BG_ALT}; + height: 12px; + border: none; + }} + QScrollBar::handle:horizontal {{ + background-color: {COLOR_SCROLLBAR}; + min-width: 20px; + border-radius: 3px; + margin: 2px; + }} + QScrollBar::handle:horizontal:hover {{ + background-color: {COLOR_SCROLLBAR_HOVER}; + }} + QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ + width: 0px; + }} + QDialogButtonBox QPushButton {{ + min-width: 70px; + }} + QFrame[frameShape="4"], QFrame[frameShape="5"] {{ + background-color: {COLOR_DARK_BORDER}; + }} +""" + + +class NumericTableWidgetItem(QTableWidgetItem): + """Custom QTableWidgetItem that sorts numerically instead of alphabetically""" + def __init__(self, text, numeric_value): + super().__init__(text) + self.numeric_value = numeric_value + + def __lt__(self, other): + """Override less-than comparison for sorting""" + if isinstance(other, NumericTableWidgetItem): + return self.numeric_value < other.numeric_value + return super().__lt__(other) + + +def log_debug(message): + """Log a debug message if logging is enabled""" + if is_logging_enabled(): + print(f"DEBUG: {message}") + +def log_error(message): + """Log an error message if logging is enabled""" + if is_logging_enabled(): + print(f"ERROR: {message}") + +def log_info(message): + """Log an info message if logging is enabled""" + if is_logging_enabled(): + print(f"INFO: {message}") + + +class SearchThread(QThread): + """Thread for handling search operations""" + results_ready = pyqtSignal(list) + error_occurred = pyqtSignal(str) + status_update = pyqtSignal(str) + + def __init__(self, api_client: ThreeDecisionAPIClient, search_query: str): + super().__init__() + self.api_client = api_client + self.search_query = search_query + + def run(self): + try: + self.status_update.emit("Submitting search...") + job_response = self.api_client.submit_search(self.search_query) + + if not job_response: + self.error_occurred.emit("Failed to submit search") + return + + if 'structures_info' in job_response: + structures = job_response['structures_info'] + self.status_update.emit(f"Search completed. Found {len(structures)} structures.") + self.results_ready.emit(structures) + return + + if job_response.get('polling_needed'): + job_id = job_response.get('id') + queue_name = job_response.get('queue', 'basicSearch') + + if not job_id: + self.error_occurred.emit("No job ID received from search") + return + + self.status_update.emit(f"Job submitted (ID: {job_id}). Waiting for completion...") + + max_attempts = 60 + attempt = 0 + + while attempt < max_attempts: + try: + result = self.api_client.get_job_status(queue_name, job_id) + + if result: + progress = result.get('progress', 0) + self.status_update.emit(f"Search progress: {progress}%") + + structure_ids = [] + if 'returnvalue' in result and 'STRUCTURE_ID' in result['returnvalue']: + structure_ids = result['returnvalue']['STRUCTURE_ID'] + + if progress == 100 or (isinstance(structure_ids, list) and len(structure_ids) == 0 and progress > 0): + if structure_ids: + self.status_update.emit("Fetching structure details...") + structures = self.api_client.get_structures_info(structure_ids) + self.results_ready.emit(structures) + else: + self.status_update.emit("Search completed with no results.") + self.results_ready.emit([]) + return + elif result.get('status') == 'failed': + self.error_occurred.emit("Search job failed") + return + + self.msleep(2000) + attempt += 1 + + except Exception as e: + log_error(f"Polling error: {e}") + attempt += 1 + self.msleep(2000) + + self.error_occurred.emit("Search timed out waiting for completion") + return + + job_id = job_response.get('id') + queue_name = job_response.get('queue', 'basicSearch') + + if not job_id: + self.error_occurred.emit("No job ID received from search") + return + + if job_response.get('status') == 'completed': + structure_ids = job_response.get('result', []) + if structure_ids: + self.status_update.emit("Fetching structure details...") + structures = self.api_client.get_structures_info(structure_ids) + self.results_ready.emit(structures) + else: + self.results_ready.emit([]) + return + + self.status_update.emit(f"Polling for results (Job ID: {job_id})...") + + if job_id == 'direct': + return + + max_attempts = 30 + attempt = 0 + + while attempt < max_attempts: + try: + result = self.api_client.get_job_status(queue_name, job_id) + + if result and result.get('status') == 'completed': + structure_ids = result.get('result', []) + if structure_ids: + self.status_update.emit("Fetching structure details...") + structures = self.api_client.get_structures_info(structure_ids) + self.results_ready.emit(structures) + else: + self.results_ready.emit([]) + return + elif result and result.get('status') == 'failed': + self.error_occurred.emit("Search job failed") + return + + except Exception as e: + log_error(f"Polling attempt {attempt + 1} failed: {e}") + + attempt += 1 + self.msleep(2000) + + self.error_occurred.emit("Search timed out") + + except Exception as e: + self.error_occurred.emit(f"Search error: {str(e)}") + + +def get_object_name(external_code: str, label: str = None, source: str = None, title: str = None, internal_id: str = None) -> str: + """ + Determine the best object name for a structure. + + Naming logic: + - For public domain structures (RCSB PDB, PDB, AlphaFold, etc.): use external_code + - For private/internal structures: use the attribute configured in settings + """ + public_sources = [ + 'rcsb', 'pdb', 'alphafold', 'uniprot', 'chembl', 'drugbank', + 'pubchem', 'zinc', 'emdb', 'wwpdb' + ] + + is_public = False + if source: + source_lower = source.lower() + is_public = any(ps in source_lower for ps in public_sources) + + if is_public: + name = external_code + else: + naming_attr = get_private_structure_naming_attribute() + + if naming_attr == 'label' and label and label.strip() and label.lower() not in ['n/a', 'null', 'none', '']: + name = label.strip() + elif naming_attr == 'title' and title and title.strip() and title.lower() not in ['n/a', 'null', 'none', '']: + name = title.strip() + elif naming_attr == 'internal_id' and internal_id and internal_id.strip() and internal_id.lower() not in ['n/a', 'null', 'none', '']: + name = internal_id.strip() + else: + name = external_code + + if name.lower().startswith('3dec_'): + name = name[5:] + + # Sanitize name (alphanumeric with underscores) + sanitized = ''.join(c if c.isalnum() or c == '_' else '_' for c in name) + + if not sanitized: + sanitized = external_code if external_code else 'structure' + + return sanitized + + +class LoadStructureThread(QThread): + """Thread for loading structures into Maestro""" + structure_loaded = pyqtSignal(str, str) # structure_id, object_name + error_occurred = pyqtSignal(str) + status_update = pyqtSignal(str) + all_structures_loaded = pyqtSignal() + pdb_content_ready = pyqtSignal(str, str, dict) # pdb_content, object_name, metadata + + def __init__(self, api_client: ThreeDecisionAPIClient, structure_data: List[Dict[str, str]]): + super().__init__() + self.api_client = api_client + self.structure_data = structure_data + + def run(self): + try: + has_matrices = any(s.get('matrix') is not None for s in self.structure_data) + + if has_matrices: + log_debug("Loading structures with transformation matrices using batch export") + self.status_update.emit("Loading structures with transformations...") + + # Define identity matrix once outside the loop for efficiency + identity_matrix = [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0 + ] + + structures_with_transforms = [] + for structure_info in self.structure_data: + structure_id = int(structure_info['structure_id']) + external_code = structure_info['external_code'] + matrix = structure_info.get('matrix') + + if matrix: + if isinstance(matrix, list) and len(matrix) == 4: + flat_matrix = [] + for row in matrix: + if isinstance(row, list) and len(row) == 4: + flat_matrix.extend(row) + else: + log_error(f"Invalid matrix row format for structure {structure_id}") + flat_matrix = None + break + + if flat_matrix and len(flat_matrix) == 16: + structures_with_transforms.append({ + "structure_id": structure_id, + "external_code": external_code, + "transform": flat_matrix + }) + else: + structures_with_transforms.append({ + "structure_id": structure_id, + "external_code": external_code, + "transform": identity_matrix + }) + else: + structures_with_transforms.append({ + "structure_id": structure_id, + "external_code": external_code, + "transform": identity_matrix + }) + else: + structures_with_transforms.append({ + "structure_id": structure_id, + "external_code": external_code, + "transform": identity_matrix + }) + + pdb_files_dict = self.api_client.export_structures_with_transforms(structures_with_transforms) + + if pdb_files_dict: + loaded_count = 0 + + for structure_info in self.structure_data: + structure_id = structure_info['structure_id'] + external_code = structure_info['external_code'] + label = structure_info.get('label') + source = structure_info.get('source') + title = structure_info.get('title') + + internal_id = None + if get_private_structure_naming_attribute() == 'internal_id': + internal_id = self.api_client.get_structure_internal_id(structure_id) + + object_name = get_object_name(external_code, label, source, title, internal_id) + + pdb_content = None + for filename, content in pdb_files_dict.items(): + filename_lower = filename.lower() + code_lower = external_code.lower() + if code_lower in filename_lower or filename_lower.replace('3dec_', '').startswith(code_lower): + pdb_content = content + log_debug(f"Matched {external_code} to file {filename}") + break + + if not pdb_content and len(pdb_files_dict) == 1: + pdb_content = list(pdb_files_dict.values())[0] + log_debug(f"Using single PDB file for {external_code}") + + if pdb_content: + metadata = { + '3decision_structure_id': structure_id, + '3decision_external_code': external_code, + '3decision_label': label or '', + '3decision_source': source or 'unknown' + } + self.pdb_content_ready.emit(pdb_content, object_name, metadata) + self.structure_loaded.emit(structure_id, object_name) + loaded_count += 1 + else: + log_error(f"Could not find PDB content for {external_code}") + self.error_occurred.emit(f"Could not find PDB content for {external_code}") + + if loaded_count == 0: + self.error_occurred.emit("Failed to load any structures") + else: + self.error_occurred.emit("Failed to load structures with transformations") + else: + log_debug("Loading structures individually without transformations") + for structure_info in self.structure_data: + structure_id = structure_info['structure_id'] + external_code = structure_info['external_code'] + label = structure_info.get('label') + source = structure_info.get('source') + title = structure_info.get('title') + + self.status_update.emit(f"Loading structure {external_code} ({structure_id})...") + + pdb_content = self.api_client.export_structure_pdb(structure_id) + + if pdb_content: + internal_id = None + if get_private_structure_naming_attribute() == 'internal_id': + internal_id = self.api_client.get_structure_internal_id(structure_id) + + object_name = get_object_name(external_code, label, source, title, internal_id) + + metadata = { + '3decision_structure_id': structure_id, + '3decision_external_code': external_code, + '3decision_label': label or '', + '3decision_source': source or 'unknown' + } + self.pdb_content_ready.emit(pdb_content, object_name, metadata) + log_info(f"3decision Plugin: Loaded {object_name} with structure_id: {structure_id}") + self.structure_loaded.emit(structure_id, object_name) + else: + self.error_occurred.emit(f"Failed to load structure {external_code} ({structure_id})") + + self.all_structures_loaded.emit() + + except Exception as e: + self.error_occurred.emit(f"Loading error: {str(e)}") + + +class SettingsDialog(QDialog): + """Settings dialog for configuring 3decision API""" + + def __init__(self, api_client: ThreeDecisionAPIClient, parent=None): + super().__init__(parent) + self.api_client = api_client + self.test_thread = None + self.init_ui() + self.load_current_settings() + + def init_ui(self): + """Initialize the user interface""" + self.setWindowTitle("3decision API Settings") + self.setModal(True) + self.setMinimumSize(500, 250) + + # Apply shared dark theme stylesheet + self.setStyleSheet(DARK_STYLESHEET) + + main_layout = QVBoxLayout() + main_layout.setContentsMargins(20, 20, 20, 20) + + # Form layout + form_layout = QFormLayout() + form_layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) + + # URL input + self.url_input = QLineEdit() + self.url_input.setPlaceholderText("https://your-3decision-api-url.discngine.cloud") + self.url_input.setMinimumWidth(300) + form_layout.addRow("3decision URL:", self.url_input) + + # API Key input + self.api_key_input = QLineEdit() + self.api_key_input.setEchoMode(QLineEdit.Password) + self.api_key_input.setPlaceholderText("Your API key") + self.api_key_input.setMinimumWidth(300) + form_layout.addRow("API Key:", self.api_key_input) + + # SSL verification checkbox + self.ssl_verify_checkbox = QCheckBox("Verify SSL certificates") + self.ssl_verify_checkbox.setToolTip( + "Enable SSL certificate verification for API requests.\n" + "Disable only for testing with self-signed certificates." + ) + form_layout.addRow("", self.ssl_verify_checkbox) + + # Log events checkbox + self.log_events_checkbox = QCheckBox("Log events") + self.log_events_checkbox.setToolTip("Enable logging of plugin events to console") + form_layout.addRow("", self.log_events_checkbox) + + # Private structure naming attribute dropdown + self.naming_attribute_combo = QComboBox() + self.naming_attribute_combo.addItems(["label", "title", "external_code", "internal_id"]) + self.naming_attribute_combo.setToolTip( + "Choose which attribute to use for naming private structures.\n" + "Public structures (PDB, AlphaFold, etc.) always use external_code." + ) + form_layout.addRow("Private structure name:", self.naming_attribute_combo) + + main_layout.addLayout(form_layout) + + # Test connection button + test_layout = QHBoxLayout() + test_layout.addStretch() + + self.test_button = QPushButton("Test Connection") + self.test_button.clicked.connect(self.test_connection) + test_layout.addWidget(self.test_button) + + main_layout.addLayout(test_layout) + + # Status label + self.status_label = QLabel("") + self.status_label.setWordWrap(True) + main_layout.addWidget(self.status_label) + + # Dialog buttons + button_box = QDialogButtonBox( + QDialogButtonBox.Save | QDialogButtonBox.Cancel, + Qt.Horizontal + ) + button_box.accepted.connect(self.save_settings) + button_box.rejected.connect(self.reject) + main_layout.addWidget(button_box) + + self.setLayout(main_layout) + + def load_current_settings(self): + """Load current settings into the form""" + if self.api_client.base_url: + self.url_input.setText(self.api_client.base_url) + if self.api_client.api_key: + self.api_key_input.setText(self.api_client.api_key) + + # Load SSL verification setting + self.ssl_verify_checkbox.setChecked(is_ssl_verify_enabled()) + + try: + from .api_client import is_logging_enabled, get_private_structure_naming_attribute + except ImportError: + from api_client import is_logging_enabled, get_private_structure_naming_attribute + self.log_events_checkbox.setChecked(is_logging_enabled()) + + naming_attr = get_private_structure_naming_attribute() + index = self.naming_attribute_combo.findText(naming_attr) + if index >= 0: + self.naming_attribute_combo.setCurrentIndex(index) + + def test_connection(self): + """Test the API connection""" + url = self.url_input.text().strip() + api_key = self.api_key_input.text().strip() + + if not url: + QMessageBox.warning(self, "Warning", "Please enter a 3decision URL") + return + + if not api_key: + QMessageBox.warning(self, "Warning", "Please enter an API key") + return + + self.test_button.setEnabled(False) + self.status_label.setText("Testing connection...") + self.status_label.setStyleSheet(f"color: {COLOR_ACCENT};") + + # Configure and test + old_url = self.api_client.base_url + old_key = self.api_client.api_key + old_token = self.api_client.token + + self.api_client.configure(url, api_key) + + if self.api_client.test_connection(): + self.status_label.setText("Connection successful!") + self.status_label.setStyleSheet(f"color: {COLOR_SUCCESS}; font-weight: bold;") + else: + self.status_label.setText("Connection failed. Please check your URL and API key.") + self.status_label.setStyleSheet(f"color: {COLOR_ERROR}; font-weight: bold;") + # Restore old settings + self.api_client.base_url = old_url + self.api_client.api_key = old_key + self.api_client.token = old_token + + self.test_button.setEnabled(True) + + def save_settings(self): + """Save the settings""" + url = self.url_input.text().strip() + api_key = self.api_key_input.text().strip() + + if not url: + QMessageBox.warning(self, "Warning", "Please enter a 3decision URL") + return + + if not api_key: + QMessageBox.warning(self, "Warning", "Please enter an API key") + return + + # Configure and save API settings + self.api_client.configure(url, api_key) + self.api_client.save_config() + + # Save SSL verification setting + ssl_enabled = self.ssl_verify_checkbox.isChecked() + set_ssl_verify_enabled(ssl_enabled) + self.api_client.save_ssl_setting(ssl_enabled) + # Apply to current session + self.api_client.session.verify = ssl_enabled + + # Save logging setting + log_enabled = self.log_events_checkbox.isChecked() + try: + from .api_client import set_logging_enabled, set_private_structure_naming_attribute + except ImportError: + from api_client import set_logging_enabled, set_private_structure_naming_attribute + set_logging_enabled(log_enabled) + self.api_client.save_logging_setting(log_enabled) + + # Save private structure naming attribute setting + naming_attr = self.naming_attribute_combo.currentText() + set_private_structure_naming_attribute(naming_attr) + self.api_client.save_naming_attribute_setting(naming_attr) + + # Try to login + if self.api_client.login(): + QMessageBox.information(self, "Success", "Settings saved and login successful!") + self.accept() + else: + QMessageBox.warning( + self, + "Warning", + "Settings saved but login failed. Please check your credentials." + ) + + +# Main Panel Class +if MAESTRO_AVAILABLE and af2: + class ThreeDecisionPanel(af2.App): + """Main 3decision plugin panel for Maestro""" + + def setPanelOptions(self): + """Set panel options""" + super().setPanelOptions() + self.title = "3decision Structure Search v1.0" + self.maestro_dockable = True + self.help_topic = "" + self.ui = None + + def setup(self): + """Set up the panel""" + super().setup() + self.api_client = ThreeDecisionAPIClient() + self.search_thread = None + self.load_thread = None + self.all_results = [] + self.projects_data = [] + self.current_project_structures = [] + self.projects_loaded = False + self.current_structure = None + self.current_structure_id = None + self.current_external_code = None + self.current_transform_matrix = None + + def layOut(self): + """Lay out the panel widgets""" + super().layOut() + main_layout = self.main_layout + + # Apply dark theme stylesheet to main panel + self.setStyleSheet(DARK_STYLESHEET) + + # Force dark background using palette + self.setPalette(create_dark_palette()) + self.setAutoFillBackground(True) + + # Also style the af2 status bar if it exists + if hasattr(self, 'status_bar') and self.status_bar: + self.status_bar.setStyleSheet(f""" + QStatusBar {{ + background-color: {COLOR_DARK_BG_ALT}; + color: {COLOR_TEXT}; + border-top: 1px solid {COLOR_DARK_BORDER}; + }} + QLabel {{ + color: {COLOR_TEXT}; + background-color: transparent; + }} + QPushButton, QToolButton {{ + background-color: {COLOR_BUTTON}; + color: {COLOR_TEXT}; + border: 1px solid {COLOR_BUTTON_BORDER}; + border-radius: 3px; + padding: 3px 8px; + }} + QPushButton:hover, QToolButton:hover {{ + background-color: {COLOR_BUTTON_HOVER}; + border: 1px solid {COLOR_ACCENT}; + }} + QProgressBar {{ + background-color: {COLOR_DARK_BG_ALT}; + border: 1px solid {COLOR_DARK_BORDER}; + border-radius: 3px; + text-align: center; + color: {COLOR_TEXT}; + }} + QProgressBar::chunk {{ + background-color: {COLOR_ACCENT}; + }} + """) + + # Also style the bottom bar if it exists + if hasattr(self, 'bottom_bar') and self.bottom_bar: + self.bottom_bar.setStyleSheet(f""" + QWidget {{ + background-color: {COLOR_DARK_BG_ALT}; + color: {COLOR_TEXT}; + }} + QPushButton, QToolButton {{ + background-color: {COLOR_BUTTON}; + color: {COLOR_TEXT}; + border: 1px solid {COLOR_BUTTON_BORDER}; + border-radius: 3px; + padding: 4px 10px; + }} + QPushButton:hover, QToolButton:hover {{ + background-color: {COLOR_BUTTON_HOVER}; + border: 1px solid {COLOR_ACCENT}; + }} + QLabel {{ + color: {COLOR_TEXT}; + background-color: transparent; + }} + """) + + # Style the bottom_line separator if it exists + if hasattr(self, 'bottom_line') and self.bottom_line: + self.bottom_line.setStyleSheet(f"background-color: {COLOR_DARK_BORDER};") + + # Settings button + settings_layout = QHBoxLayout() + settings_layout.addStretch() + + self.settings_button = QPushButton("Settings") + self.settings_button.clicked.connect(self.open_settings) + settings_layout.addWidget(self.settings_button) + + main_layout.addLayout(settings_layout) + + # Tab widget + self.tab_widget = QTabWidget() + + # Search tab + self.search_widget = self._create_search_tab() + self.tab_widget.addTab(self.search_widget, "Search") + + # Projects tab + self.projects_widget = self._create_projects_tab() + self.tab_widget.addTab(self.projects_widget, "Projects") + + # Associated Files tab + self.files_widget = self._create_files_tab() + self.tab_widget.addTab(self.files_widget, "Associated Files") + + main_layout.addWidget(self.tab_widget) + + # Status label + self.status_label = QLabel("Not logged in") + self.status_label.setStyleSheet(f"color: {COLOR_ERROR}; padding: 5px; background-color: {COLOR_DARK_BG};") + self.status_label.setAutoFillBackground(True) + main_layout.addWidget(self.status_label) + + # Apply dark palette to all child widgets recursively + self._apply_dark_palette_recursive(self) + + # Check login status + self.check_login_status() + + def _apply_dark_palette_recursive(self, widget): + """Apply dark palette to widget and all its children""" + dark_palette = create_dark_palette() + + widget.setPalette(dark_palette) + widget.setAutoFillBackground(True) + + for child in widget.findChildren(QWidget): + child.setPalette(dark_palette) + child.setAutoFillBackground(True) + + def _create_search_tab(self): + """Create the search tab content""" + widget = QWidget() + layout = QVBoxLayout() + + # Search section + search_layout = QHBoxLayout() + + search_layout.addWidget(QLabel("Search:")) + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Enter search term (e.g., ABL1)") + self.search_input.returnPressed.connect(self.submit_search) + search_layout.addWidget(self.search_input) + + self.submit_button = QPushButton("Submit") + self.submit_button.clicked.connect(self.submit_search) + self.submit_button.setEnabled(False) + search_layout.addWidget(self.submit_button) + + layout.addLayout(search_layout) + + # Progress bar + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + layout.addWidget(self.progress_bar) + + # Column filters + filters_label = QLabel("Filter Results:") + filters_label.setStyleSheet(f"font-weight: bold; margin-top: 10px; color: {COLOR_TEXT};") + layout.addWidget(filters_label) + + filters_layout = QHBoxLayout() + + self.filter_external_code = QLineEdit() + self.filter_external_code.setPlaceholderText("External Code") + self.filter_external_code.textChanged.connect(self.apply_filters) + filters_layout.addWidget(self.filter_external_code) + + self.filter_label = QLineEdit() + self.filter_label.setPlaceholderText("Label") + self.filter_label.textChanged.connect(self.apply_filters) + filters_layout.addWidget(self.filter_label) + + self.filter_title = QLineEdit() + self.filter_title.setPlaceholderText("Title") + self.filter_title.textChanged.connect(self.apply_filters) + filters_layout.addWidget(self.filter_title) + + self.filter_method = QLineEdit() + self.filter_method.setPlaceholderText("Method") + self.filter_method.textChanged.connect(self.apply_filters) + filters_layout.addWidget(self.filter_method) + + self.filter_resolution = QLineEdit() + self.filter_resolution.setPlaceholderText("Resolution (e.g., <2.0)") + self.filter_resolution.textChanged.connect(self.apply_filters) + filters_layout.addWidget(self.filter_resolution) + + self.filter_source = QLineEdit() + self.filter_source.setPlaceholderText("Source") + self.filter_source.textChanged.connect(self.apply_filters) + filters_layout.addWidget(self.filter_source) + + clear_filters_btn = QPushButton("Clear") + clear_filters_btn.clicked.connect(self.clear_filters) + filters_layout.addWidget(clear_filters_btn) + + layout.addLayout(filters_layout) + + # Results table + self.results_table = QTableWidget() + self.results_table.setColumnCount(6) + self.results_table.setHorizontalHeaderLabels([ + "External Code", "Label", "Title", "Method", "Resolution", "Source" + ]) + self.results_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.results_table.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.results_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.results_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.results_table.setAlternatingRowColors(True) + self.results_table.setSortingEnabled(True) + + layout.addWidget(self.results_table) + + # Load button + button_layout = QHBoxLayout() + button_layout.addStretch() + + self.load_button = QPushButton("Load Selected in Maestro") + self.load_button.clicked.connect(self.load_selected_structures) + self.load_button.setEnabled(False) + button_layout.addWidget(self.load_button) + + layout.addLayout(button_layout) + + widget.setLayout(layout) + return widget + + def _create_projects_tab(self): + """Create the projects tab content""" + widget = QWidget() + layout = QVBoxLayout() + + layout.addWidget(QLabel("Browse and load structures from your 3decision projects")) + + # Projects table + layout.addWidget(QLabel("Projects:")) + + # Projects filters + projects_filters_layout = QHBoxLayout() + + self.projects_filter_name = QLineEdit() + self.projects_filter_name.setPlaceholderText("Project Name") + self.projects_filter_name.textChanged.connect(self.apply_projects_filters) + projects_filters_layout.addWidget(self.projects_filter_name) + + self.projects_filter_owner = QLineEdit() + self.projects_filter_owner.setPlaceholderText("Owner") + self.projects_filter_owner.textChanged.connect(self.apply_projects_filters) + projects_filters_layout.addWidget(self.projects_filter_owner) + + self.projects_filter_structures = QLineEdit() + self.projects_filter_structures.setPlaceholderText("Structures (e.g., >10)") + self.projects_filter_structures.textChanged.connect(self.apply_projects_filters) + projects_filters_layout.addWidget(self.projects_filter_structures) + + self.projects_filter_id = QLineEdit() + self.projects_filter_id.setPlaceholderText("Project ID") + self.projects_filter_id.textChanged.connect(self.apply_projects_filters) + projects_filters_layout.addWidget(self.projects_filter_id) + + clear_projects_filters_btn = QPushButton("Clear") + clear_projects_filters_btn.clicked.connect(self.clear_projects_filters) + projects_filters_layout.addWidget(clear_projects_filters_btn) + + layout.addLayout(projects_filters_layout) + + self.projects_table = QTableWidget() + self.projects_table.setColumnCount(4) + self.projects_table.setHorizontalHeaderLabels([ + "Project Name", "Owner", "Structures", "Project ID" + ]) + + header = self.projects_table.horizontalHeader() + header.setStretchLastSection(True) + header.resizeSection(0, 200) + header.resizeSection(1, 120) + header.resizeSection(2, 80) + header.resizeSection(3, 150) + + self.projects_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.projects_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.projects_table.setAlternatingRowColors(True) + self.projects_table.setSortingEnabled(True) + self.projects_table.itemSelectionChanged.connect(self.on_project_selection_changed) + + layout.addWidget(self.projects_table) + + # Projects buttons + projects_buttons = QHBoxLayout() + refresh_button = QPushButton("Refresh Projects") + refresh_button.clicked.connect(self.load_projects_for_tab) + projects_buttons.addWidget(refresh_button) + projects_buttons.addStretch() + layout.addLayout(projects_buttons) + + # Project structures list + layout.addWidget(QLabel("Project Structures:")) + + # Project structures filters + project_filters_layout = QHBoxLayout() + + self.project_filter_external_code = QLineEdit() + self.project_filter_external_code.setPlaceholderText("External Code") + self.project_filter_external_code.textChanged.connect(self.apply_project_filters) + project_filters_layout.addWidget(self.project_filter_external_code) + + self.project_filter_label = QLineEdit() + self.project_filter_label.setPlaceholderText("Label") + self.project_filter_label.textChanged.connect(self.apply_project_filters) + project_filters_layout.addWidget(self.project_filter_label) + + self.project_filter_title = QLineEdit() + self.project_filter_title.setPlaceholderText("Title") + self.project_filter_title.textChanged.connect(self.apply_project_filters) + project_filters_layout.addWidget(self.project_filter_title) + + self.project_filter_method = QLineEdit() + self.project_filter_method.setPlaceholderText("Method") + self.project_filter_method.textChanged.connect(self.apply_project_filters) + project_filters_layout.addWidget(self.project_filter_method) + + clear_project_filters_btn = QPushButton("Clear") + clear_project_filters_btn.clicked.connect(self.clear_project_filters) + project_filters_layout.addWidget(clear_project_filters_btn) + + layout.addLayout(project_filters_layout) + + self.project_structures_table = QTableWidget() + self.project_structures_table.setColumnCount(5) + self.project_structures_table.setHorizontalHeaderLabels([ + "External Code", "Label", "Title", "Method", "Files" + ]) + self.project_structures_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.project_structures_table.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.project_structures_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + header = self.project_structures_table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.Stretch) + header.setSectionResizeMode(4, QHeaderView.Fixed) # Files column fixed width + self.project_structures_table.setColumnWidth(4, 80) + self.project_structures_table.setAlternatingRowColors(True) + self.project_structures_table.setSortingEnabled(True) + + layout.addWidget(self.project_structures_table) + + # Structure selection buttons + structure_buttons = QHBoxLayout() + select_all_btn = QPushButton("Select All") + select_all_btn.clicked.connect(self.select_all_project_structures) + structure_buttons.addWidget(select_all_btn) + + select_none_btn = QPushButton("Select None") + select_none_btn.clicked.connect(self.select_none_project_structures) + structure_buttons.addWidget(select_none_btn) + + structure_buttons.addStretch() + + self.load_project_structures_button = QPushButton("Load Selected Structures") + self.load_project_structures_button.clicked.connect(self.load_selected_project_structures) + self.load_project_structures_button.setEnabled(False) + structure_buttons.addWidget(self.load_project_structures_button) + + layout.addLayout(structure_buttons) + + widget.setLayout(layout) + return widget + + def _create_files_tab(self): + """Create the associated files tab""" + widget = QWidget() + layout = QVBoxLayout() + + # Header with current structure info + self.files_header_label = QLabel("Associated Files - Select a structure from the Projects tab to view its files") + self.files_header_label.setStyleSheet(f"font-weight: bold; padding: 5px; color: {COLOR_TEXT};") + layout.addWidget(self.files_header_label) + + # Checkbox to apply transformation matrix + transform_layout = QHBoxLayout() + self.apply_transform_checkbox = QCheckBox("Apply transformation matrix to associated files (if available)") + self.apply_transform_checkbox.setToolTip("When enabled, associated files will be transformed using the structure's reference transformation matrix") + self.apply_transform_checkbox.setChecked(True) + transform_layout.addWidget(self.apply_transform_checkbox) + transform_layout.addStretch() + layout.addLayout(transform_layout) + + # Files table + self.files_table = QTableWidget() + self.files_table.setColumnCount(5) + self.files_table.setHorizontalHeaderLabels(["File Name", "Type", "Size", "Format", "Description"]) + self.files_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.files_table.setAlternatingRowColors(True) + self.files_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.files_table.setSortingEnabled(True) + + header = self.files_table.horizontalHeader() + header.setStretchLastSection(True) + header.setSectionResizeMode(QHeaderView.Interactive) + self.files_table.setColumnWidth(0, 200) + self.files_table.setColumnWidth(1, 100) + self.files_table.setColumnWidth(2, 80) + self.files_table.setColumnWidth(3, 100) + + self.files_table.itemSelectionChanged.connect(self._on_file_selection_changed) + layout.addWidget(self.files_table) + + # Buttons for file operations + files_buttons = QHBoxLayout() + + self.refresh_files_button = QPushButton("Refresh Files") + self.refresh_files_button.clicked.connect(self.refresh_associated_files) + self.refresh_files_button.setEnabled(False) + files_buttons.addWidget(self.refresh_files_button) + + files_buttons.addStretch() + + self.open_file_button = QPushButton("Open in Maestro") + self.open_file_button.clicked.connect(self.open_selected_file) + self.open_file_button.setEnabled(False) + files_buttons.addWidget(self.open_file_button) + + self.open_system_button = QPushButton("Download && Open") + self.open_system_button.setToolTip("Download the file and open it with the system's default application") + self.open_system_button.clicked.connect(self.download_and_open_with_system) + self.open_system_button.setEnabled(False) + files_buttons.addWidget(self.open_system_button) + + layout.addLayout(files_buttons) + + # Status label for file operations + self.files_status_label = QLabel("") + self.files_status_label.setStyleSheet(f"color: {COLOR_TEXT_DIM}; padding: 5px;") + layout.addWidget(self.files_status_label) + + widget.setLayout(layout) + return widget + + def check_login_status(self): + """Check if user is logged in and update UI accordingly""" + if self.api_client.is_configured() and self.api_client.test_connection(): + self.status_label.setText("Logged in successfully") + self.status_label.setStyleSheet(f"color: {COLOR_SUCCESS};") + self.submit_button.setEnabled(True) + + if hasattr(self, 'projects_table') and not self.projects_loaded: + try: + self.load_projects_for_tab() + self.projects_loaded = True + except Exception as e: + log_error(f"Failed to auto-load projects: {e}") + else: + self.status_label.setText("Not logged in - click Settings to configure") + self.status_label.setStyleSheet(f"color: {COLOR_ERROR};") + self.submit_button.setEnabled(False) + + def open_settings(self): + """Open the settings dialog""" + dialog = SettingsDialog(self.api_client, self) + if dialog.exec() == QDialog.Accepted: + self.check_login_status() + + def submit_search(self): + """Submit search query""" + query = self.search_input.text().strip() + if not query: + QMessageBox.warning(self, "Warning", "Please enter a search term") + return + + if not self.api_client.is_configured(): + QMessageBox.warning(self, "Warning", "Please configure API settings first") + return + + self.search_thread = SearchThread(self.api_client, query) + self.search_thread.results_ready.connect(self.display_results) + self.search_thread.error_occurred.connect(self.handle_search_error) + self.search_thread.status_update.connect(self.update_status) + + self.submit_button.setEnabled(False) + self.progress_bar.setVisible(True) + self.progress_bar.setRange(0, 0) + + self.search_thread.start() + + def update_status(self, message: str): + """Update status message""" + self.status_label.setText(message) + + def display_results(self, structures: List[Dict[str, Any]]): + """Display search results in the table""" + self.progress_bar.setVisible(False) + self.submit_button.setEnabled(True) + self.check_login_status() + + self.all_results = structures + self.populate_results_table(structures) + + if len(structures) == 0: + QMessageBox.information(self, "Search Results", "No structures found for your search query.") + else: + QMessageBox.information(self, "Search Results", f"Found {len(structures)} structures.") + + def populate_results_table(self, structures: List[Dict[str, Any]]): + """Populate the results table with given structures""" + self.results_table.setSortingEnabled(False) + self.results_table.setRowCount(len(structures)) + + for row, structure in enumerate(structures): + general = structure.get('general', {}) + + external_code = general.get('external_code', 'N/A') + item = QTableWidgetItem(external_code) + item.setData(Qt.UserRole, general.get('structure_id')) + self.results_table.setItem(row, 0, item) + + label = general.get('label', 'N/A') + self.results_table.setItem(row, 1, QTableWidgetItem(label)) + + title = general.get('title', 'N/A') + self.results_table.setItem(row, 2, QTableWidgetItem(title)) + + method = general.get('method', 'N/A') + self.results_table.setItem(row, 3, QTableWidgetItem(method)) + + resolution = general.get('resolution') + resolution_text = f"{resolution:.2f} A" if resolution else "N/A" + numeric_value = resolution if resolution is not None else float('inf') + resolution_item = NumericTableWidgetItem(resolution_text, numeric_value) + self.results_table.setItem(row, 4, resolution_item) + + source = general.get('source', 'N/A') + self.results_table.setItem(row, 5, QTableWidgetItem(source)) + + self.results_table.setSortingEnabled(True) + self.load_button.setEnabled(len(structures) > 0) + + def handle_search_error(self, error_message: str): + """Handle search errors""" + self.progress_bar.setVisible(False) + self.submit_button.setEnabled(True) + self.check_login_status() + QMessageBox.critical(self, "Search Error", error_message) + + def load_selected_structures(self): + """Load selected structures into Maestro""" + selected_structures = [] + + selected_rows = self.results_table.selectionModel().selectedRows() + log_debug(f"Found {len(selected_rows)} selected rows in search results") + + for index in selected_rows: + row = index.row() + item = self.results_table.item(row, 0) + if item: + structure_id = item.data(Qt.UserRole) + external_code = item.text() + + label_item = self.results_table.item(row, 1) + title_item = self.results_table.item(row, 2) + source_item = self.results_table.item(row, 5) + label = label_item.text() if label_item else None + title = title_item.text() if title_item else None + source = source_item.text() if source_item else None + + if structure_id: + selected_structures.append({ + 'structure_id': str(structure_id), + 'external_code': external_code, + 'label': label, + 'title': title, + 'source': source + }) + + if not selected_structures: + QMessageBox.warning(self, "Warning", "Please select at least one structure to load") + return + + self.load_thread = LoadStructureThread(self.api_client, selected_structures) + self.load_thread.pdb_content_ready.connect(self.load_pdb_into_maestro) + self.load_thread.structure_loaded.connect(self.handle_structure_loaded) + self.load_thread.error_occurred.connect(self.handle_load_error) + self.load_thread.status_update.connect(self.update_status) + self.load_thread.all_structures_loaded.connect(self.handle_all_structures_loaded) + + self.load_button.setEnabled(False) + self.progress_bar.setVisible(True) + self.progress_bar.setRange(0, 0) + + self.load_thread.start() + + def load_pdb_into_maestro(self, pdb_content: str, object_name: str, metadata: dict): + """Load PDB content into Maestro""" + try: + # Save PDB to temporary file + temp_dir = tempfile.gettempdir() + # Sanitize object_name to prevent path traversal + safe_name = sanitize_filename(object_name) + pdb_filename = f"{safe_name}.pdb" + pdb_path = os.path.join(temp_dir, pdb_filename) + + with open(pdb_path, 'w') as f: + f.write(pdb_content) + + log_debug(f"Saved PDB to: {pdb_path}") + + # Read structure using Schrodinger's structure module + st = structure.StructureReader.read(pdb_path) + + # Add metadata as properties + for key, value in metadata.items(): + st.property[f's_user_{key}'] = str(value) + + # Set the structure title + st.title = object_name + + # Import into Maestro project + if MAESTRO_AVAILABLE: + pt = maestro.project_table_get() + pt.importStructure(st) + log_info(f"Imported structure {object_name} into Maestro project") + else: + log_debug(f"Would import structure {object_name} into Maestro (not in Maestro environment)") + + # Clean up temp file + try: + os.remove(pdb_path) + except FileNotFoundError: + # File already removed or never created + pass + except OSError as e: + log_debug(f"Could not remove temporary PDB file {pdb_path}: {e}") + + except Exception as e: + log_error(f"Error loading PDB into Maestro: {e}") + + def handle_structure_loaded(self, structure_id: str, object_name: str): + """Handle successful structure loading""" + log_info(f"Loaded structure {structure_id} as {object_name}") + + def handle_all_structures_loaded(self): + """Handle completion of all structure loading""" + self.progress_bar.setVisible(False) + self.load_button.setEnabled(True) + if hasattr(self, 'load_project_structures_button'): + self.load_project_structures_button.setEnabled(True) + self.check_login_status() + + def handle_load_error(self, error_message: str): + """Handle structure loading errors""" + self.progress_bar.setVisible(False) + self.load_button.setEnabled(True) + if hasattr(self, 'load_project_structures_button'): + self.load_project_structures_button.setEnabled(True) + QMessageBox.critical(self, "Load Error", error_message) + + # Filter methods for search results + def clear_filters(self): + """Clear all filter inputs without triggering filtering""" + self.filter_external_code.blockSignals(True) + self.filter_label.blockSignals(True) + self.filter_title.blockSignals(True) + self.filter_method.blockSignals(True) + self.filter_resolution.blockSignals(True) + self.filter_source.blockSignals(True) + + self.filter_external_code.clear() + self.filter_label.clear() + self.filter_title.clear() + self.filter_method.clear() + self.filter_resolution.clear() + self.filter_source.clear() + + self.filter_external_code.blockSignals(False) + self.filter_label.blockSignals(False) + self.filter_title.blockSignals(False) + self.filter_method.blockSignals(False) + self.filter_resolution.blockSignals(False) + self.filter_source.blockSignals(False) + + # Show all results + if hasattr(self, 'all_results') and self.all_results: + self.populate_results_table(self.all_results) + self.status_label.setText(f"Showing all {len(self.all_results)} results") + + def apply_filters(self): + """Apply column filters to the results table""" + if not hasattr(self, 'all_results') or not self.all_results: + return + + filter_ext_code = self.filter_external_code.text().strip().lower() + filter_lbl = self.filter_label.text().strip().lower() + filter_ttl = self.filter_title.text().strip().lower() + filter_mth = self.filter_method.text().strip().lower() + filter_res = self.filter_resolution.text().strip() + filter_src = self.filter_source.text().strip().lower() + + res_min, res_max, res_operator = self.parse_resolution_filter(filter_res) + + filtered_structures = [] + for struct in self.all_results: + general = struct.get('general', {}) + + if filter_ext_code and filter_ext_code not in general.get('external_code', '').lower(): + continue + if filter_lbl and filter_lbl not in general.get('label', '').lower(): + continue + if filter_ttl and filter_ttl not in general.get('title', '').lower(): + continue + if filter_mth and filter_mth not in general.get('method', '').lower(): + continue + if filter_src and filter_src not in general.get('source', '').lower(): + continue + + if filter_res: + resolution = general.get('resolution') + if not self.check_resolution_filter(resolution, res_min, res_max, res_operator): + continue + + filtered_structures.append(struct) + + self.populate_results_table(filtered_structures) + + if len(filtered_structures) < len(self.all_results): + self.status_label.setText(f"Showing {len(filtered_structures)} of {len(self.all_results)} results") + else: + self.status_label.setText(f"Showing all {len(self.all_results)} results") + + def parse_resolution_filter(self, filter_text: str): + """ + Parse resolution filter string. + Supports formats: <2.0, >1.5, <=2.0, >=1.5, 1.5-3.0, 2.0 + Returns: (min_value, max_value, operator) + """ + if not filter_text: + return None, None, None + + filter_text = filter_text.strip() + + # Range format: 1.5-3.0 + if '-' in filter_text and not filter_text.startswith('-'): + parts = filter_text.split('-') + if len(parts) == 2: + try: + min_val = float(parts[0].strip()) + max_val = float(parts[1].strip()) + return min_val, max_val, 'range' + except ValueError: + return None, None, None + + # Operator formats: <, >, <=, >= + if filter_text.startswith('<='): + try: + val = float(filter_text[2:].strip()) + return None, val, '<=' + except ValueError: + return None, None, None + elif filter_text.startswith('>='): + try: + val = float(filter_text[2:].strip()) + return val, None, '>=' + except ValueError: + return None, None, None + elif filter_text.startswith('<'): + try: + val = float(filter_text[1:].strip()) + return None, val, '<' + except ValueError: + return None, None, None + elif filter_text.startswith('>'): + try: + val = float(filter_text[1:].strip()) + return val, None, '>' + except ValueError: + return None, None, None + + # Exact value + try: + val = float(filter_text) + return val, val, '=' + except ValueError: + return None, None, None + + def check_resolution_filter(self, resolution, min_val, max_val, operator): + """Check if resolution value passes the filter""" + if resolution is None: + return False + + if operator == 'range': + return min_val <= resolution <= max_val + elif operator == '<': + return resolution < max_val + elif operator == '<=': + return resolution <= max_val + elif operator == '>': + return resolution > min_val + elif operator == '>=': + return resolution >= min_val + elif operator == '=': + return abs(resolution - min_val) < 0.01 + + return True + + # Projects tab methods + def load_projects_for_tab(self): + """Load projects when Projects tab is accessed""" + try: + if not self.api_client.is_authenticated(): + log_debug("Not authenticated, attempting to authenticate") + if self.api_client.test_connection(): + log_debug("Successfully authenticated") + self.check_login_status() + else: + reply = QMessageBox.question( + self, + "Authentication Required", + "You need to be logged in to view projects.\n\n" + "Would you like to open settings to configure your API token?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes + ) + + if reply == QMessageBox.Yes: + self.open_settings() + return + + projects = self.api_client.get_projects() + + # Filter out the "3decision" project + filtered_projects = [] + for project in projects: + project_label = project.get('project_label', '').lower() + project_name = project.get('project_name', '').lower() + + if project_label != '3decision' and project_name != '3decision': + filtered_projects.append(project) + + self.projects_data = filtered_projects + self.populate_projects_table() + + except Exception as e: + log_error(f"Failed to load projects: {e}") + QMessageBox.critical(self, "Error", f"Failed to load projects: {str(e)}") + + def populate_projects_table(self): + """Populate the projects table with data""" + self.projects_table.setSortingEnabled(False) + self.projects_table.setRowCount(len(self.projects_data)) + + for row, project in enumerate(self.projects_data): + name_item = QTableWidgetItem(project.get('project_label', '')) + name_item.setFlags(name_item.flags() & ~Qt.ItemIsEditable) + name_item.setData(Qt.UserRole, project) + self.projects_table.setItem(row, 0, name_item) + + owner = '' + for field in ['owner', 'project_owner', 'created_by', 'owner_username', 'creator', 'author', 'username']: + if project.get(field): + owner = project.get(field) + break + + owner_item = QTableWidgetItem(str(owner)) + owner_item.setFlags(owner_item.flags() & ~Qt.ItemIsEditable) + self.projects_table.setItem(row, 1, owner_item) + + count = project.get('count_structures_in_project', 0) + count_item = NumericTableWidgetItem(str(count), count) + count_item.setFlags(count_item.flags() & ~Qt.ItemIsEditable) + self.projects_table.setItem(row, 2, count_item) + + id_item = QTableWidgetItem(str(project.get('project_id', ''))) + id_item.setFlags(id_item.flags() & ~Qt.ItemIsEditable) + self.projects_table.setItem(row, 3, id_item) + + self.projects_table.setSortingEnabled(True) + + def clear_projects_filters(self): + """Clear all projects filter inputs without triggering filtering""" + self.projects_filter_name.blockSignals(True) + self.projects_filter_owner.blockSignals(True) + self.projects_filter_structures.blockSignals(True) + self.projects_filter_id.blockSignals(True) + + self.projects_filter_name.clear() + self.projects_filter_owner.clear() + self.projects_filter_structures.clear() + self.projects_filter_id.clear() + + self.projects_filter_name.blockSignals(False) + self.projects_filter_owner.blockSignals(False) + self.projects_filter_structures.blockSignals(False) + self.projects_filter_id.blockSignals(False) + + self.apply_projects_filters() + + def apply_projects_filters(self): + """Apply column filters to the projects table""" + if not hasattr(self, 'projects_data') or not self.projects_data: + return + + filter_name = self.projects_filter_name.text().strip().lower() + filter_owner = self.projects_filter_owner.text().strip().lower() + filter_structures = self.projects_filter_structures.text().strip() + filter_id = self.projects_filter_id.text().strip().lower() + + struct_min, struct_max, struct_operator = self.parse_resolution_filter(filter_structures) + + filtered_projects = [] + for project in self.projects_data: + project_name = str(project.get('project_label', '')).lower() + + owner = '' + for field in ['owner', 'project_owner', 'created_by', 'owner_username', 'creator', 'author', 'username']: + if project.get(field): + owner = str(project.get(field)).lower() + break + + structures_count = project.get('count_structures_in_project', 0) + project_id = str(project.get('project_id', '')).lower() + + if filter_name and filter_name not in project_name: + continue + if filter_owner and filter_owner not in owner: + continue + if filter_id and filter_id not in project_id: + continue + + if filter_structures: + if not self.check_resolution_filter(structures_count, struct_min, struct_max, struct_operator): + continue + + filtered_projects.append(project) + + self.projects_table.setSortingEnabled(False) + self.projects_table.setRowCount(len(filtered_projects)) + + for row, project in enumerate(filtered_projects): + name_item = QTableWidgetItem(project.get('project_label', '')) + name_item.setFlags(name_item.flags() & ~Qt.ItemIsEditable) + name_item.setData(Qt.UserRole, project) + self.projects_table.setItem(row, 0, name_item) + + owner = '' + for field in ['owner', 'project_owner', 'created_by', 'owner_username', 'creator', 'author', 'username']: + if project.get(field): + owner = project.get(field) + break + + owner_item = QTableWidgetItem(str(owner)) + owner_item.setFlags(owner_item.flags() & ~Qt.ItemIsEditable) + self.projects_table.setItem(row, 1, owner_item) + + count = project.get('count_structures_in_project', 0) + count_item = NumericTableWidgetItem(str(count), count) + count_item.setFlags(count_item.flags() & ~Qt.ItemIsEditable) + self.projects_table.setItem(row, 2, count_item) + + id_item = QTableWidgetItem(str(project.get('project_id', ''))) + id_item.setFlags(id_item.flags() & ~Qt.ItemIsEditable) + self.projects_table.setItem(row, 3, id_item) + + self.projects_table.setSortingEnabled(True) + + def on_project_selection_changed(self): + """Handle project table selection change""" + selected_rows = self.projects_table.selectionModel().selectedRows() + if selected_rows: + self.load_project_structures_button.setEnabled(False) + row = selected_rows[0].row() + project_data = self.projects_table.item(row, 0).data(Qt.UserRole) + project_id = project_data.get('project_id') + self.load_project_structures_in_tab(project_id) + + def load_project_structures_in_tab(self, project_id): + """Load structures for the selected project""" + try: + structures = self.api_client.get_project_structures(project_id) + + log_debug(f"Received {len(structures)} structures from get_project_structures") + + if not structures: + self.current_project_structures = [] + self.populate_project_structures_table([]) + return + + structure_ids = [] + for s in structures: + structure_id = s.get('STRUCTURE_ID') or s.get('structure_id') + if structure_id: + structure_ids.append(int(structure_id)) + + log_debug(f"Fetching details for {len(structure_ids)} structures...") + + detailed_structures = self.api_client.get_structures_info(structure_ids) + + details_map = {} + for detail in detailed_structures: + sid = detail.get('structure_id') or detail.get('general', {}).get('structure_id') + if sid: + details_map[int(sid)] = detail + + enriched_structures = [] + for s in structures: + structure_id = s.get('STRUCTURE_ID') or s.get('structure_id') + if structure_id and int(structure_id) in details_map: + enriched = details_map[int(structure_id)].copy() + matrix = None + ref_transforms = s.get('ReferenceTransforms') + if ref_transforms and isinstance(ref_transforms, dict): + transform = ref_transforms.get('transform') + if transform and isinstance(transform, list) and len(transform) == 16: + matrix = [ + transform[0:4], + transform[4:8], + transform[8:12], + transform[12:16] + ] + enriched['TRANSFORM_MATRIX'] = matrix + enriched_structures.append(enriched) + else: + enriched_structures.append(s) + + self.current_project_structures = enriched_structures + self.populate_project_structures_table(enriched_structures) + + except Exception as e: + log_error(f"Failed to load project structures: {e}") + QMessageBox.critical(self, "Error", f"Failed to load project structures: {str(e)}") + + def populate_project_structures_table(self, structures): + """Populate the project structures table""" + self.project_structures_table.setSortingEnabled(False) + self.project_structures_table.setRowCount(len(structures)) + + for row, structure in enumerate(structures): + external_code = (structure.get('EXTERNAL_CODE') or + structure.get('external_code') or + structure.get('general', {}).get('external_code', 'N/A')) + + item = QTableWidgetItem(external_code) + + structure_id = (structure.get('STRUCTURE_ID') or + structure.get('structure_id') or + structure.get('general', {}).get('structure_id')) + + transform_matrix = (structure.get('TRANSFORM_MATRIX') or + structure.get('matrix') or + structure.get('MATRIX')) + + source = (structure.get('SOURCE') or + structure.get('source') or + structure.get('general', {}).get('source')) + + item.setData(Qt.UserRole, { + 'structure_id': structure_id, + 'matrix': transform_matrix, + 'source': source + }) + self.project_structures_table.setItem(row, 0, item) + + label = (structure.get('LABEL') or + structure.get('label') or + structure.get('PROJECT_LABEL') or + structure.get('project_label') or + structure.get('general', {}).get('label', external_code)) + self.project_structures_table.setItem(row, 1, QTableWidgetItem(label)) + + title = (structure.get('TITLE') or + structure.get('title') or + structure.get('general', {}).get('title', external_code)) + self.project_structures_table.setItem(row, 2, QTableWidgetItem(title)) + + method = (structure.get('METHOD') or + structure.get('method') or + structure.get('general', {}).get('method', 'N/A')) + self.project_structures_table.setItem(row, 3, QTableWidgetItem(method)) + + # Files button + files_button = QPushButton("View") + files_button.setProperty('structure_data', structure) + files_button.clicked.connect(lambda checked, s=structure: self.view_structure_files(s)) + self.project_structures_table.setCellWidget(row, 4, files_button) + + self.project_structures_table.setSortingEnabled(True) + self.load_project_structures_button.setEnabled(len(structures) > 0) + + def clear_project_filters(self): + """Clear all project structure filter inputs without triggering filtering""" + self.project_filter_external_code.blockSignals(True) + self.project_filter_label.blockSignals(True) + self.project_filter_title.blockSignals(True) + self.project_filter_method.blockSignals(True) + + self.project_filter_external_code.clear() + self.project_filter_label.clear() + self.project_filter_title.clear() + self.project_filter_method.clear() + + self.project_filter_external_code.blockSignals(False) + self.project_filter_label.blockSignals(False) + self.project_filter_title.blockSignals(False) + self.project_filter_method.blockSignals(False) + + self.apply_project_filters() + + def apply_project_filters(self): + """Apply column filters to the project structures table""" + if not hasattr(self, 'current_project_structures') or not self.current_project_structures: + return + + filter_ext_code = self.project_filter_external_code.text().strip().lower() + filter_lbl = self.project_filter_label.text().strip().lower() + filter_ttl = self.project_filter_title.text().strip().lower() + filter_mth = self.project_filter_method.text().strip().lower() + + filtered_structures = [] + for struct in self.current_project_structures: + external_code = (struct.get('EXTERNAL_CODE') or + struct.get('external_code') or + struct.get('general', {}).get('external_code', 'N/A')) + + label = (struct.get('LABEL') or + struct.get('label') or + struct.get('PROJECT_LABEL') or + struct.get('project_label') or + struct.get('general', {}).get('label', external_code)) + + title = (struct.get('TITLE') or + struct.get('title') or + struct.get('general', {}).get('title', external_code)) + + method = (struct.get('METHOD') or + struct.get('method') or + struct.get('general', {}).get('method', 'N/A')) + + external_code_lower = str(external_code).lower() if external_code else '' + label_lower = str(label).lower() if label else '' + title_lower = str(title).lower() if title else '' + method_lower = str(method).lower() if method else '' + + if filter_ext_code and filter_ext_code not in external_code_lower: + continue + if filter_lbl and filter_lbl not in label_lower: + continue + if filter_ttl and filter_ttl not in title_lower: + continue + if filter_mth and filter_mth not in method_lower: + continue + + filtered_structures.append(struct) + + self.populate_project_structures_table(filtered_structures) + + def select_all_project_structures(self): + """Select all structures in the project structures table""" + self.project_structures_table.selectAll() + + def select_none_project_structures(self): + """Deselect all structures in the project structures table""" + self.project_structures_table.clearSelection() + + def load_selected_project_structures(self): + """Load selected structures from the project""" + selected_structures = [] + + selected_rows = self.project_structures_table.selectionModel().selectedRows() + log_debug(f"Found {len(selected_rows)} selected rows") + + for index in selected_rows: + row = index.row() + item = self.project_structures_table.item(row, 0) + if item: + data = item.data(Qt.UserRole) + external_code = item.text() + + label_item = self.project_structures_table.item(row, 1) + label = label_item.text() if label_item else None + + title_item = self.project_structures_table.item(row, 2) + title = title_item.text() if title_item else None + + if isinstance(data, dict): + structure_id = data.get('structure_id') + matrix = data.get('matrix') + source = data.get('source') + else: + structure_id = data + matrix = None + source = None + + if structure_id: + selected_structures.append({ + 'structure_id': str(structure_id), + 'external_code': external_code, + 'label': label, + 'title': title, + 'source': source, + 'matrix': matrix + }) + + if not selected_structures: + QMessageBox.warning(self, "Warning", "Please select at least one structure to load") + return + + self.load_thread = LoadStructureThread(self.api_client, selected_structures) + self.load_thread.pdb_content_ready.connect(self.load_pdb_into_maestro) + self.load_thread.structure_loaded.connect(self.handle_structure_loaded) + self.load_thread.error_occurred.connect(self.handle_load_error) + self.load_thread.status_update.connect(self.update_status) + self.load_thread.all_structures_loaded.connect(self.handle_all_structures_loaded) + + self.load_project_structures_button.setEnabled(False) + self.status_label.setText("Loading structures...") + + self.load_thread.start() + + # ============================================= + # Associated Files functionality + # ============================================= + + def view_structure_files(self, structure): + """View associated files for a structure""" + if not structure: + return + + structure_id = str(structure.get('STRUCTURE_ID') or structure.get('structure_id') or + structure.get('general', {}).get('structure_id', '')) + external_code = (structure.get('EXTERNAL_CODE') or structure.get('external_code') or + structure.get('general', {}).get('external_code', structure_id)) + + # Store current structure for file operations + self.current_structure = structure + self.current_structure_id = structure_id + self.current_external_code = external_code + + # Extract and store transformation matrix if available + self.current_transform_matrix = None + matrix = structure.get('TRANSFORM_MATRIX') + if not matrix: + ref_transforms = structure.get('ReferenceTransforms') + if ref_transforms and isinstance(ref_transforms, dict): + transform = ref_transforms.get('transform') + if transform and isinstance(transform, list) and len(transform) == 16: + matrix = [ + transform[0:4], + transform[4:8], + transform[8:12], + transform[12:16] + ] + self.current_transform_matrix = matrix + + # Update header and switch to files tab + matrix_status = " (with transformation matrix)" if matrix else "" + self.files_header_label.setText(f"Associated Files for {external_code} (ID: {structure_id}){matrix_status}") + self.tab_widget.setCurrentIndex(2) # Switch to Associated Files tab + + # Enable refresh button + self.refresh_files_button.setEnabled(True) + + # Load files using external_code + self.load_associated_files(external_code) + + def load_associated_files(self, external_code): + """Load associated files for a structure using external code""" + try: + self.files_status_label.setText("Loading associated files...") + + files = self.api_client.get_associated_files(external_code) + + if files: + self.populate_files_table(files) + self.files_status_label.setText(f"Loaded {len(files)} associated files") + else: + self.files_table.setRowCount(0) + self.files_status_label.setText("No associated files found") + + except Exception as e: + log_error(f"Failed to load associated files: {e}") + self.files_status_label.setText(f"Error loading files: {str(e)}") + self.files_table.setRowCount(0) + + def populate_files_table(self, files): + """Populate the files table with file data""" + self.files_table.setRowCount(len(files)) + + for row, file_info in enumerate(files): + filename = file_info.get('file_name', 'Unknown') + file_type = file_info.get('file_type_label', 'Unknown') + file_size = "N/A" + file_extension = file_info.get('file_type_extension', '') + file_format = self._get_file_format_from_extension(file_extension, filename) + description = file_info.get('file_desc', '') or '' + + name_item = QTableWidgetItem(filename) + name_item.setData(Qt.UserRole, file_info) + self.files_table.setItem(row, 0, name_item) + self.files_table.setItem(row, 1, QTableWidgetItem(str(file_type))) + self.files_table.setItem(row, 2, QTableWidgetItem(str(file_size))) + self.files_table.setItem(row, 3, QTableWidgetItem(file_format)) + self.files_table.setItem(row, 4, QTableWidgetItem(description)) + + def _get_file_format_from_extension(self, extension, filename=""): + """Determine file format from extension and filename""" + if not extension: + if filename and '.' in filename: + extension = filename.lower().split('.')[-1] + else: + return "Unknown" + + extension = extension.lower() + + # Maestro-supported formats and other common formats + format_map = { + 'pdb': 'PDB Structure', + 'cif': 'mmCIF Structure', + 'mmcif': 'mmCIF Structure', + 'mae': 'Maestro File', + 'maegz': 'Maestro File (compressed)', + 'sdf': 'SDF Molecule', + 'mol2': 'MOL2 Molecule', + 'mol': 'MOL Molecule', + 'xyz': 'XYZ Coordinates', + 'mtz': 'MTZ Reflection', + 'mrc': 'MRC Map', + 'map': 'CCP4 Map', + 'ccp4': 'CCP4 Map', + 'dsn6': 'DSN6 Map', + 'xplor': 'XPLOR Map', + 'dx': 'DX Map', + 'smi': 'SMILES File', + 'csv': 'CSV File', + 'txt': 'Text File', + 'pdf': 'PDF File', + 'png': 'PNG Image', + 'jpg': 'JPG Image', + 'jpeg': 'JPEG Image', + 'tif': 'TIF Image', + 'tiff': 'TIFF Image', + 'doc': 'DOC File', + 'docx': 'DOCX File', + 'xls': 'XLS File', + 'xlsx': 'XLSX File', + } + + return format_map.get(extension, f"{extension.upper()} File" if extension else "Unknown") + + def _on_file_selection_changed(self): + """Handle file selection change""" + selected_items = self.files_table.selectedItems() + has_selection = len(selected_items) > 0 + + self.open_file_button.setEnabled(has_selection) + self.open_system_button.setEnabled(has_selection) + + def refresh_associated_files(self): + """Refresh the associated files list""" + if hasattr(self, 'current_external_code') and self.current_external_code: + self.load_associated_files(self.current_external_code) + + def open_selected_file(self): + """Open the selected file in Maestro""" + selected_row = self.files_table.currentRow() + if selected_row < 0: + QMessageBox.warning(self, "Warning", "Please select a file to open") + return + + file_item = self.files_table.item(selected_row, 0) + if not file_item: + return + + file_info = file_item.data(Qt.UserRole) + filename = file_item.text() + file_format = self.files_table.item(selected_row, 3).text() + + # Maestro-supported structure formats + structure_formats = ['PDB Structure', 'mmCIF Structure', 'Maestro File', 'Maestro File (compressed)', + 'SDF Molecule', 'MOL2 Molecule', 'MOL Molecule', 'XYZ Coordinates'] + # Map/density formats that can be directly imported via visimport + supported_map_formats = ['CCP4 Map', 'MRC Map'] + # Map formats that are NOT supported for direct import + unsupported_map_formats = { + 'DSN6 Map': 'DSN6 format is not supported by Maestro.', + 'MTZ Reflection': 'MTZ files contain reflection data, not real-space maps.\nThey must be converted to CCP4 format first using tools like sf2map.', + 'XPLOR Map': 'XPLOR map format is not supported for direct import.', + 'DX Map': 'DX map format is not supported for direct import.' + } + + supported_formats = structure_formats + supported_map_formats + + # Check for unsupported map formats first + if file_format in unsupported_map_formats: + QMessageBox.warning(self, "Unsupported Map Format", + f"{unsupported_map_formats[file_format]}\n\n" + f"Use the 'Download & Open' button to save the file locally.") + return + + if file_format not in supported_formats: + QMessageBox.information(self, "Use System Application", + f"'{file_format}' files cannot be opened directly in Maestro.\n\n" + f"Use the 'Download & Open' button to open this file " + f"with your system's default application.") + return + + try: + self.files_status_label.setText(f"Opening {filename} in Maestro...") + + file_data = self.api_client.download_file(file_info) + + if file_data: + temp_dir = tempfile.gettempdir() + # Sanitize filename to prevent path traversal + safe_filename = sanitize_filename(filename) + file_path = os.path.join(temp_dir, safe_filename) + + with open(file_path, 'wb') as f: + f.write(file_data) + + log_debug(f"Saved file to: {file_path}") + + if MAESTRO_AVAILABLE: + if file_format in structure_formats: + # Load structure into Maestro + st = structure.StructureReader.read(file_path) + obj_name = os.path.splitext(filename)[0] + st.title = obj_name + + # Apply transformation matrix if enabled + if self.apply_transform_checkbox.isChecked() and self.current_transform_matrix: + try: + self._apply_transform_to_structure(st, self.current_transform_matrix) + self.files_status_label.setText(f"Opened {filename} with transformation applied") + except Exception as matrix_error: + log_error(f"Could not apply transformation matrix: {matrix_error}") + self.files_status_label.setText(f"Opened {filename} (could not apply transformation)") + else: + self.files_status_label.setText(f"Opened {filename} in Maestro") + + pt = maestro.project_table_get() + pt.importStructure(st) + log_info(f"Imported file {filename} into Maestro project") + else: + # Map files - try to import onto currently selected workspace entry + pt = maestro.project_table_get() + included_rows = list(pt.included_rows) + + if included_rows: + # Get the first included entry's ID + entry_row = included_rows[0] + entry_id = entry_row.entry_id + + # Determine map type - CCP4 and MRC are both read as ccp4 format + # file_format here is the display name like 'CCP4 Map' or 'MRC Map' + map_type = 'ccp4' # Both CCP4 and MRC use ccp4 map type + + # Use visimport command to load the map onto the entry + # Sanitize surface_name to prevent command injection + raw_surface_name = os.path.splitext(safe_filename)[0] + surface_name = sanitize_surface_name(raw_surface_name) + # Escape quotes in file path + safe_file_path = file_path.replace('"', r'\"') + cmd = f'visimport entry={entry_id} map_type={map_type} isovalue=1.0 "{safe_file_path}":::{surface_name}' + log_debug(f"Importing map with command: {cmd}") + + try: + maestro.command(cmd) + self.files_status_label.setText(f"Imported {filename} onto entry {entry_id}") + log_info(f"Imported map file {filename} onto entry {entry_id}") + except Exception as cmd_error: + log_error(f"visimport command failed: {cmd_error}") + self.files_status_label.setText(f"Map file saved to temp folder") + QMessageBox.warning( + self, "Map Import", + f"Could not automatically import map: {cmd_error}\n\n" + f"Map file saved to:\n{file_path}\n\n" + "Try using File > Import Structures/Volumes manually." + ) + else: + # No entry selected in workspace + self.files_status_label.setText(f"No entry selected - map saved to temp folder") + QMessageBox.information( + self, "Map File", + f"Map file '{filename}' has been downloaded to:\n{file_path}\n\n" + "To automatically import map files:\n" + "1. First include a structure in the Workspace\n" + "2. Then click 'Open in Maestro' for the map\n\n" + "Or use File > Import Structures/Volumes manually." + ) + else: + self.files_status_label.setText("Maestro not available") + QMessageBox.warning(self, "Warning", "Maestro is not available. Cannot open file.") + + # Clean up temp files for structure formats (map files may be needed if not auto-imported) + if file_format in structure_formats: + try: + os.remove(file_path) + except FileNotFoundError: + pass + except OSError as e: + log_debug(f"Could not remove temporary file {file_path}: {e}") + + else: + self.files_status_label.setText("Failed to download file") + QMessageBox.warning(self, "Error", "Failed to download file for opening") + + except Exception as e: + log_error(f"Error opening file: {e}") + self.files_status_label.setText(f"Error opening file: {str(e)}") + QMessageBox.critical(self, "Open Error", str(e)) + + def _apply_transform_to_structure(self, st, matrix): + """Apply a 4x4 transformation matrix to a Schrodinger structure""" + import numpy as np + + # Convert matrix to flat list if needed + if isinstance(matrix, list) and len(matrix) == 4 and isinstance(matrix[0], list): + flat_matrix = [] + for row in matrix: + flat_matrix.extend(row) + matrix = flat_matrix + + if len(matrix) != 16: + raise ValueError(f"Invalid matrix length: {len(matrix)}, expected 16") + + # Convert to 4x4 numpy array + transform = np.array(matrix).reshape(4, 4) + + # Apply transformation to all atoms + for atom in st.atom: + coords = np.array([atom.x, atom.y, atom.z, 1.0]) + new_coords = transform @ coords + atom.x, atom.y, atom.z = new_coords[:3] + + log_debug(f"Applied transformation matrix to structure") + + def download_and_open_with_system(self): + """Download the selected file and open it with the system's default application.""" + selected_row = self.files_table.currentRow() + if selected_row < 0: + QMessageBox.warning(self, "Warning", "Please select a file to download") + return + + file_item = self.files_table.item(selected_row, 0) + if not file_item: + return + + file_info = file_item.data(Qt.UserRole) + filename = file_item.text() + + try: + self.files_status_label.setText(f"Downloading {filename}...") + + file_data = self.api_client.download_file(file_info) + + if file_data: + temp_dir = os.path.join(tempfile.gettempdir(), '3decision_downloads') + os.makedirs(temp_dir, exist_ok=True) + # Sanitize filename to prevent path traversal + safe_filename = sanitize_filename(filename) + file_path = os.path.join(temp_dir, safe_filename) + + with open(file_path, 'wb') as f: + f.write(file_data) + + self.files_status_label.setText(f"Opening {safe_filename} with system application...") + + self._open_file_with_system(file_path) + + self.files_status_label.setText(f"Downloaded and opened {filename}") + + else: + self.files_status_label.setText("Failed to download file") + QMessageBox.warning(self, "Error", "Failed to download file") + + except Exception as e: + log_error(f"Error downloading file: {e}") + self.files_status_label.setText(f"Error: {str(e)}") + QMessageBox.critical(self, "Error", f"Failed to download and open file: {str(e)}") + + def _open_file_with_system(self, file_path: str): + """Open a file with the system's default application.""" + try: + if sys.platform == 'darwin': # macOS + subprocess.run(['open', file_path], check=True, timeout=30) + elif sys.platform == 'win32': # Windows + os.startfile(file_path) + else: # Linux + subprocess.run(['xdg-open', file_path], check=True, timeout=30) + except subprocess.CalledProcessError as e: + log_error(f"Failed to open file with system application (exit code {e.returncode}): {e}") + QMessageBox.warning( + self, "Error", + f"Failed to open file with the system's default application.\n\nDetails: {e}" + ) + except subprocess.TimeoutExpired: + log_error(f"Timeout while opening file: {file_path}") + QMessageBox.warning( + self, "Timeout", + "The system application took too long to respond." + ) + except OSError as e: + log_error(f"OS error while opening file: {e}") + QMessageBox.warning( + self, "Error", + f"An operating system error occurred.\n\nDetails: {e}" + ) + +else: + # Fallback for when Maestro is not available (testing outside Maestro) + class ThreeDecisionPanel(QDialog): + """Standalone dialog for testing outside Maestro""" + + _instance = None + + @classmethod + def panel(cls, run=True): + """Return or create a singleton instance""" + if cls._instance is None: + cls._instance = cls() + if run: + cls._instance.show() + return cls._instance + + def __init__(self, parent=None): + super().__init__(parent) + self.api_client = ThreeDecisionAPIClient() + self.search_thread = None + self.load_thread = None + self.all_results = [] + self.projects_data = [] + self.current_project_structures = [] + self.projects_loaded = False + + self.setWindowTitle("3decision Structure Search v1.0 (Standalone)") + self.setMinimumSize(800, 600) + self.init_ui() + self.check_login_status() + + def init_ui(self): + """Initialize the user interface""" + main_layout = QVBoxLayout() + + # Settings button + settings_layout = QHBoxLayout() + settings_layout.addStretch() + + self.settings_button = QPushButton("Settings") + self.settings_button.clicked.connect(self.open_settings) + settings_layout.addWidget(self.settings_button) + + main_layout.addLayout(settings_layout) + + # Tab widget + self.tab_widget = QTabWidget() + + # Search tab (simplified) + search_widget = QWidget() + search_layout = QVBoxLayout() + + search_row = QHBoxLayout() + search_row.addWidget(QLabel("Search:")) + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Enter search term") + search_row.addWidget(self.search_input) + + self.submit_button = QPushButton("Submit") + self.submit_button.setEnabled(False) + search_row.addWidget(self.submit_button) + + search_layout.addLayout(search_row) + + self.results_table = QTableWidget() + self.results_table.setColumnCount(6) + self.results_table.setHorizontalHeaderLabels([ + "External Code", "Label", "Title", "Method", "Resolution", "Source" + ]) + search_layout.addWidget(self.results_table) + + self.load_button = QPushButton("Load Selected") + self.load_button.setEnabled(False) + search_layout.addWidget(self.load_button) + + search_widget.setLayout(search_layout) + self.tab_widget.addTab(search_widget, "Search") + + main_layout.addWidget(self.tab_widget) + + # Status + self.status_label = QLabel("Not logged in") + self.status_label.setStyleSheet(f"color: {COLOR_ERROR};") + main_layout.addWidget(self.status_label) + + self.setLayout(main_layout) + + def check_login_status(self): + if self.api_client.is_configured() and self.api_client.test_connection(): + self.status_label.setText("Logged in successfully") + self.status_label.setStyleSheet(f"color: {COLOR_SUCCESS};") + self.submit_button.setEnabled(True) + else: + self.status_label.setText("Not logged in - click Settings") + self.status_label.setStyleSheet(f"color: {COLOR_ERROR};") + self.submit_button.setEnabled(False) + + def open_settings(self): + dialog = SettingsDialog(self.api_client, self) + if dialog.exec() == QDialog.Accepted: + self.check_login_status() diff --git a/threedecision_maestro_dir/images/.DS_Store b/threedecision_maestro_dir/images/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/threedecision_maestro_dir/images/.DS_Store differ diff --git a/threedecision_maestro_dir/images/3decision_RVB_Dark_transparent.png b/threedecision_maestro_dir/images/3decision_RVB_Dark_transparent.png new file mode 100644 index 0000000..7177659 Binary files /dev/null and b/threedecision_maestro_dir/images/3decision_RVB_Dark_transparent.png differ diff --git a/threedecision_maestro_dir/images/3decision_RVB_White_transparent.png b/threedecision_maestro_dir/images/3decision_RVB_White_transparent.png new file mode 100644 index 0000000..1550f8f Binary files /dev/null and b/threedecision_maestro_dir/images/3decision_RVB_White_transparent.png differ diff --git a/threedecision_maestro_dir/images/LogoTitle2.0.png b/threedecision_maestro_dir/images/LogoTitle2.0.png new file mode 100644 index 0000000..2058e56 Binary files /dev/null and b/threedecision_maestro_dir/images/LogoTitle2.0.png differ diff --git a/threedecision_maestro_dir/images/LogoTitle2.0.svg b/threedecision_maestro_dir/images/LogoTitle2.0.svg new file mode 100644 index 0000000..15d0eba --- /dev/null +++ b/threedecision_maestro_dir/images/LogoTitle2.0.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/threedecision_maestro_dir/images/cog-16-dark.png b/threedecision_maestro_dir/images/cog-16-dark.png new file mode 100644 index 0000000..724ad8e Binary files /dev/null and b/threedecision_maestro_dir/images/cog-16-dark.png differ diff --git a/threedecision_maestro_dir/images/cog-16-white.png b/threedecision_maestro_dir/images/cog-16-white.png new file mode 100644 index 0000000..0867d85 Binary files /dev/null and b/threedecision_maestro_dir/images/cog-16-white.png differ diff --git a/threedecision_maestro_dir/images/cog-16.png b/threedecision_maestro_dir/images/cog-16.png new file mode 100644 index 0000000..0867d85 Binary files /dev/null and b/threedecision_maestro_dir/images/cog-16.png differ