class DocumentDetail_v1
Document detail view component
/tf/active/vicechatdev/document_detail_old.py
69 - 3891
moderate
Purpose
Document detail view component
Source Code
class DocumentDetail(param.Parameterized):
"""Document detail view component"""
document_uid = param.String(default='')
doc_number = param.String(default='')
current_tab = param.String(default='overview')
def __init__(self, parent_app=None, **params):
super().__init__(**params)
self.parent_app = parent_app
self.template = None # No template needed when embedded
self.session_manager = SessionManager()
self.user = None
self.document = None
self.current_version = None
self.notification_area = pn.pane.Markdown("")
self.main_content = pn.Column(sizing_mode='stretch_width')
# Document info and actions areas
self.doc_info_area = pn.Column(sizing_mode='stretch_width')
self.doc_actions_area = pn.Column(sizing_mode='stretch_width')
# Create tabs for different sections
self.tabs = pn.layout.Tabs(sizing_mode='stretch_width')
def set_user(self, user):
"""Set the current user."""
self.user = user
return True
def load_document(self, document_uid=None, doc_number=None):
"""Load document by UID or document number."""
if document_uid:
self.document_uid = document_uid
if doc_number:
self.doc_number = doc_number
return self._load_document()
def get_document_view(self):
"""Get the document view for embedding in other panels."""
container = pn.Column(
self.notification_area,
self.main_content,
sizing_mode='stretch_width'
)
return container
def _get_current_user(self) -> DocUser:
"""Get the current user from session"""
user_id = self.session_manager.get_user_id()
if user_id:
return DocUser(uid=user_id)
return None
def _setup_header(self):
"""Set up the header with title and actions"""
# Create back button
back_btn = Button(
name='Back to Dashboard',
button_type='default',
width=150
)
back_btn.on_click(self._navigate_back)
# Create refresh button
refresh_btn = Button(
name='Refresh',
button_type='default',
width=100
)
refresh_btn.on_click(self._load_document)
# Header with buttons
header = Row(
pn.pane.Markdown("# Document Details"),
refresh_btn,
back_btn,
sizing_mode='stretch_width',
align='end'
)
self.template.header.append(header)
def _setup_sidebar(self):
"""Set up the sidebar with document actions"""
# Document info area
self.doc_info_area = Column(
sizing_mode='stretch_width'
)
# Document actions area
self.doc_actions_area = Column(
sizing_mode='stretch_width'
)
# Add to sidebar
self.template.sidebar.append(self.doc_info_area)
self.template.sidebar.append(self.doc_actions_area)
def _setup_main_area(self):
"""Set up the main area with document content tabs"""
# Create notification area
self.template.main.append(self.notification_area)
# Create tabs for different sections
self.tabs = Tabs(
sizing_mode='stretch_width'
)
# Add tabs container to main area
self.template.main.append(self.tabs)
def _load_document(self, event=None):
"""Load document data and update the UI."""
try:
# Guard against recursive calls
if hasattr(self, '_loading_document') and self._loading_document:
logger.warning("Recursive call to _load_document avoided")
return False
self._loading_document = True
try:
# Clear notification
self.notification_area.object = ""
# Clear existing UI elements
self.main_content.clear()
self.doc_info_area.clear()
self.doc_actions_area.clear()
self.tabs.clear()
# Get document details using document_uid directly
if self.document_uid:
# Only fetch document if we don't already have it
if not self.document:
document_data = get_document(document_uid=self.document_uid)
if not document_data:
self.notification_area.object = "**Error:** Document not found"
return False
# Store the document
self.document = document_data
# Extract properties from document data
self._extract_document_properties()
# Create header
doc_header = pn.Column(
pn.pane.Markdown(f"# {self.doc_title or 'Untitled Document'}"),
pn.pane.Markdown(f"**Document Number:** {self.doc_number or 'No number'} | " +
f"**Revision:** {self.doc_revision or 'None'} | " +
f"**Status:** {self.doc_status or 'Unknown'}"),
sizing_mode='stretch_width'
)
# Add header to main content
self.main_content.append(doc_header)
# Set up document info
self._setup_document_info()
# Set up document actions
self._setup_document_actions()
# Create and add tabs for document content
self._create_document_tabs()
# Add document info and actions to main content (in a row to mimic sidebar)
info_actions = pn.Row(
self.doc_info_area,
pn.layout.HSpacer(width=20), # Spacing
self.doc_actions_area
)
# Create layout
layout = pn.Column(
doc_header,
info_actions,
self.tabs,
sizing_mode='stretch_width'
)
# Update main content
self.main_content.clear()
self.main_content.append(layout)
return True
else:
self.notification_area.object = "**Error:** No document UID provided"
return False
finally:
# Always clear the loading flag
self._loading_document = False
except Exception as e:
self._loading_document = False # Ensure flag is cleared
self.notification_area.object = f"**Error:** {str(e)}"
logger.error(f"Error loading document: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return False
def _extract_document_properties(self):
"""Extract document properties from document data."""
if not self.document:
return
# Case-insensitive property extraction
def get_property(data, possible_keys, default=''):
if not data:
return default
for key in possible_keys:
if hasattr(data, 'get'):
value = data.get(key)
if value:
return value
return default
# Extract document metadata with case-insensitive lookup
self.document_uid = get_property(self.document, ['UID', 'uid'])
self.doc_title = get_property(self.document, ['title', 'Title'])
self.doc_number = get_property(self.document, ['docNumber', 'doc_number', 'documentNumber'])
self.doc_revision = get_property(self.document, ['revision', 'Revision'])
self.doc_status = get_property(self.document, ['status', 'Status'])
self.doc_type = get_property(self.document, ['docType', 'doc_type', 'documentType'])
self.doc_department = get_property(self.document, ['department', 'Department'])
self.doc_owner = get_property(self.document, ['ownerName', 'owner_name', 'owner'])
self.doc_owner_uid = get_property(self.document, ['ownerUID', 'owner_uid'])
self.doc_creator = get_property(self.document, ['creatorName', 'creator_name', 'creator'])
self.doc_creator_uid = get_property(self.document, ['creatorUID', 'creator_uid'])
self.doc_created_date = get_property(self.document, ['createdDate', 'created_date', 'created'])
self.doc_modified_date = get_property(self.document, ['modifiedDate', 'modified_date', 'modified'])
# Get document content if available directly or fetch it if needed
self.doc_content = get_property(self.document, ['content', 'text', 'doc_text'])
def load_document_data(self, document_data):
"""
Load document directly from document data.
Parameters:
-----------
document_data : dict
The document data to load
"""
logger.debug(f"Loading document from data: {type(document_data)}")
try:
# Debug the document data keys
if hasattr(document_data, 'keys'):
logger.debug(f"Document data keys: {document_data.keys()}")
# Store the document data
self.document = document_data
# Case-insensitive property extraction
def get_property(data, possible_keys, default=''):
if not data:
return default
for key in possible_keys:
if hasattr(data, 'get'):
value = data.get(key)
if value:
return value
return default
# Extract document metadata with case-insensitive lookup
self.document_uid = get_property(document_data, ['UID', 'uid']) # This is correct
self.doc_title = get_property(document_data, ['title', 'Title'])
self.doc_number = get_property(document_data, ['docNumber', 'doc_number', 'documentNumber'])
self.doc_revision = get_property(document_data, ['revision', 'Revision'])
self.doc_status = get_property(document_data, ['status', 'Status'])
self.doc_type = get_property(document_data, ['docType', 'doc_type', 'documentType'])
self.doc_department = get_property(document_data, ['department', 'Department'])
self.doc_owner = get_property(document_data, ['ownerName', 'owner_name', 'owner'])
self.doc_owner_uid = get_property(document_data, ['ownerUID', 'owner_uid'])
self.doc_creator = get_property(document_data, ['creatorName', 'creator_name', 'creator'])
self.doc_creator_uid = get_property(document_data, ['creatorUID', 'creator_uid'])
self.doc_created_date = get_property(document_data, ['createdDate', 'created_date', 'created'])
self.doc_modified_date = get_property(document_data, ['modifiedDate', 'modified_date', 'modified'])
# Get document content if available directly or fetch it if needed
self.doc_content = get_property(document_data, ['content', 'text', 'doc_text'])
# FIX: Use self.document_uid instead of self.doc_uid
if not self.doc_content and self.document_uid:
# Fetch content if not included in document data
from CDocs.controllers.document_controller import get_document_content
content_result = get_document_content(self.document_uid)
if content_result and isinstance(content_result, dict):
self.doc_content = content_result.get('content', '')
# Just return True - don't try to call _load_document() which will cause infinite recursion
logger.debug("Document data loaded successfully")
return True
except Exception as e:
logger.error(f"Error loading document data: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
# Display error message in UI
if hasattr(self, 'notification_area'):
self.notification_area.object = f"**Error:** {str(e)}"
return False
def _setup_document_info(self):
"""Set up document info panel in sidebar"""
# Document status with appropriate styling
status_code = self.doc_status or 'DRAFT'
status_name = settings.get_document_status_name(status_code)
status_color = settings.get_status_color(status_code)
# Basic document info
doc_info = pn.Column(
pn.pane.Markdown(f"## {self.doc_number or 'No document number'}"),
pn.pane.Markdown(f"**Title:** {self.doc_title or 'Untitled'}"),
pn.pane.Markdown(f"**Type:** {settings.get_document_type_name(self.doc_type)}"),
pn.pane.Markdown(f"**Department:** {settings.get_department_name(self.doc_department)}"),
pn.pane.Markdown(f"**Status:** <span style='color:{status_color};font-weight:bold;'>{status_name}</span>"),
pn.pane.Markdown(f"**Owner:** {self.doc_owner or 'Unassigned'}"),
pn.pane.Markdown(f"**Created:** {self._format_date(self.doc_created_date)}"),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
self.doc_info_area.append(doc_info)
# Include version info code from the original method
# Get current version
self.current_version = self.document.get('current_version')
# Version info if available
if self.current_version:
# Determine which file path to show based on document status
primary_file_path = None
secondary_file_path = None
# Get file paths from current version
word_file_path = self.current_version.get('word_file_path') or self.current_version.get('fileCloudWordPath')
pdf_file_path = self.current_version.get('pdf_file_path') or self.current_version.get('fileCloudPdfPath')
# Set file paths based on document status
if is_published_status(status_code):
primary_file_path = pdf_file_path
secondary_file_path = word_file_path
primary_label = "PDF File"
secondary_label = "Editable File"
else:
primary_file_path = word_file_path
secondary_file_path = pdf_file_path
primary_label = "Editable File"
secondary_label = "PDF File"
# Build version info panel
version_info_items = [
pn.pane.Markdown(f"### Current Version"),
pn.pane.Markdown(f"**Version:** {self.current_version.get('version_number', '')}"),
pn.pane.Markdown(f"**Created by:** {self.current_version.get('created_by_name', '')}"),
pn.pane.Markdown(f"**Date:** {self._format_date(self.current_version.get('created_date'))}")
]
# Add file path information if available
if primary_file_path:
# Truncate path for display if it's too long
display_path = primary_file_path
if len(display_path) > 40:
display_path = "..." + display_path[-40:]
version_info_items.append(pn.pane.Markdown(f"**{primary_label}:** {display_path}"))
if secondary_file_path:
# Truncate path for display if it's too long
display_path = secondary_file_path
if len(display_path) > 40:
display_path = "..." + display_path[-40:]
version_info_items.append(pn.pane.Markdown(f"**{secondary_label}:** {display_path}"))
version_info = pn.Column(
*version_info_items,
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'mt-3']
)
self.doc_info_area.append(version_info)
else:
self.doc_info_area.append(doc_info)
self.doc_info_area.append(pn.pane.Markdown("*No versions available*"))
def _setup_document_actions(self):
"""Set up document action buttons in sidebar"""
# Create action button container
self.doc_actions_area.append(Markdown("## Actions"))
# Different actions based on document status and user permissions
status = self.document.get('status', '')
# View/download button always available
if self.current_version:
view_btn = pn.widgets.Button(name="View Document", button_type="primary", width=200)
view_btn.on_click(self._view_document)
self.doc_actions_area.append(view_btn)
# Add EDIT button for documents in editable states
if is_editable_status(status) and permissions.user_has_permission(self.user, "EDIT_DOCUMENT"):
edit_btn = pn.widgets.Button(name="Edit Online", button_type="warning", width=200)
edit_btn.on_click(self._edit_document_online)
self.doc_actions_area.append(edit_btn)
# Edit metadata button - available if user has edit permission
if permissions.user_has_permission(self.user, "EDIT_DOCUMENT"):
edit_btn = pn.widgets.Button(name="Edit Metadata", button_type="default", width=200)
edit_btn.on_click(self._show_edit_form)
self.doc_actions_area.append(edit_btn)
# Upload new version - available if user has create version permission
if permissions.user_has_permission(self.user, "CREATE_VERSION"):
upload_btn = pn.widgets.Button(name="Upload New Version", button_type="default", width=200)
upload_btn.on_click(self._show_upload_form)
self.doc_actions_area.append(upload_btn)
# Add PDF convert button for editable documents
if is_editable_status(status) and permissions.user_has_permission(self.user, "CONVERT_DOCUMENT"):
convert_btn = pn.widgets.Button(name="Convert to PDF", button_type="default", width=200)
convert_btn.on_click(self._convert_to_pdf)
self.doc_actions_area.append(convert_btn)
# Review button - available for draft documents if user has review initiation permission
if status in ['DRAFT'] and permissions.user_has_permission(self.user, "INITIATE_REVIEW"):
review_btn = Button(name="Start Review", button_type="default", width=200)
review_btn.on_click(self._show_review_form)
self.doc_actions_area.append(review_btn)
# Approval button - available for approved documents if user has approval initiation permission
if status in ['DRAFT', 'IN_REVIEW'] and permissions.user_has_permission(self.user, "INITIATE_APPROVAL"):
approval_btn = Button(name="Start Approval", button_type="default", width=200)
approval_btn.on_click(self._show_approval_form)
self.doc_actions_area.append(approval_btn)
# Publish button - available for approved documents if user has publish permission
if status in ['APPROVED'] and permissions.user_has_permission(self.user, "PUBLISH_DOCUMENT"):
publish_btn = Button(name="Publish Document", button_type="success", width=200)
publish_btn.on_click(self._show_publish_form)
self.doc_actions_area.append(publish_btn)
# Archive button - available for published documents if user has archive permission
if status in ['PUBLISHED', 'EFFECTIVE'] and permissions.user_has_permission(self.user, "ARCHIVE_DOCUMENT"):
archive_btn = Button(name="Archive Document", button_type="danger", width=200)
archive_btn.on_click(self._show_archive_form)
self.doc_actions_area.append(archive_btn)
# Clone button - always available if user has create document permission
if permissions.user_has_permission(self.user, "CREATE_DOCUMENT"):
clone_btn = Button(name="Clone Document", button_type="default", width=200)
clone_btn.on_click(self._show_clone_form)
self.doc_actions_area.append(clone_btn)
def _create_document_tabs(self):
"""Create tabs for different document content sections"""
# Overview tab
overview_tab = self._create_overview_tab()
# Versions tab
versions_tab = self._create_versions_tab()
# Reviews tab
reviews_tab = self._create_reviews_tab()
# Approvals tab
approvals_tab = self._create_approvals_tab()
# Audit trail tab
audit_tab = self._create_audit_tab()
# Add tabs to the tabs container
self.tabs.extend([
('Overview', overview_tab),
('Versions', versions_tab),
('Reviews', reviews_tab),
('Approvals', approvals_tab),
('Audit Trail', audit_tab)
])
def _create_overview_tab(self):
"""Create the overview tab content"""
# Basic overview information
description = self.document.get('description', 'No description available')
# Key metadata from document
created_date = self._format_date(self.document.get('createdDate'))
modified_date = self._format_date(self.document.get('modifiedDate'))
effective_date = self._format_date(self.document.get('effective_date'))
expiry_date = self._format_date(self.document.get('expiry_date'))
# Current version preview if available
preview_pane = Column(
sizing_mode='stretch_width',
height=600
)
if self.current_version:
try:
# Add document viewer
doc_viewer = self._create_document_viewer()
preview_pane.append(doc_viewer)
except Exception as e:
logger.error(f"Error creating document preview: {e}")
preview_pane.append(Markdown("*Error loading document preview*"))
else:
preview_pane.append(Markdown("*No document version available for preview*"))
# Document lifecycle dates
dates_section = Column(
Markdown("### Document Timeline"),
Markdown(f"**Created:** {created_date}"),
Markdown(f"**Last Modified:** {modified_date}"),
Markdown(f"**Effective Date:** {effective_date or 'Not set'}"),
Markdown(f"**Expiry Date:** {expiry_date or 'Not set'}"),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
# Create layout
overview_layout = Column(
Markdown(f"# {self.document.get('title')}"),
Markdown(f"## Description"),
Markdown(description),
Row(
dates_section,
sizing_mode='stretch_width'
),
Markdown("## Document Preview"),
preview_pane,
sizing_mode='stretch_width'
)
return overview_layout
def _create_versions_tab(self):
"""Create the versions tab content"""
logger = logging.getLogger('CDocs.ui.document_detail')
logger.debug("Creating versions tab")
# Get versions from document with error handling
versions = []
try:
# Debug the document structure
logger.debug(f"Document keys: {list(self.document.keys() if isinstance(self.document, dict) else [])}")
# Try accessing versions from the document
if isinstance(self.document, dict):
if 'versions' in self.document and isinstance(self.document['versions'], list):
versions = self.document['versions']
logger.debug(f"Found {len(versions)} versions in document['versions']")
elif 'document_versions' in self.document and isinstance(self.document['document_versions'], list):
versions = self.document['document_versions']
logger.debug(f"Found {len(versions)} versions in document['document_versions']")
# If no versions found in document, fetch them directly
if not versions and hasattr(self, 'document_uid') and self.document_uid:
logger.debug(f"No versions found in document, fetching directly for {self.document_uid}")
try:
from CDocs.controllers.document_controller import get_document_versions
version_result = get_document_versions(self.document_uid)
logger.debug(f"get_document_versions result: {version_result}")
if version_result and version_result.get('success') and 'versions' in version_result:
versions = version_result['versions']
logger.debug(f"Loaded {len(versions)} versions from direct API call")
except Exception as version_err:
logger.error(f"Error fetching versions: {version_err}")
import traceback
logger.error(traceback.format_exc())
except Exception as e:
logger.error(f"Error accessing document versions: {e}")
import traceback
logger.error(traceback.format_exc())
versions = []
# Debug the versions we found
logger.debug(f"Final versions count: {len(versions)}")
if versions and len(versions) > 0:
logger.debug(f"First version keys: {list(versions[0].keys() if isinstance(versions[0], dict) else [])}")
if not versions:
# Create upload new version button if user has permission
upload_btn = Button(
name="Upload New Version",
button_type="primary",
width=150,
disabled=not permissions.user_has_permission(self.user, "CREATE_VERSION")
)
upload_btn.on_click(self._show_upload_form)
return pn.Column(
pn.pane.Markdown("# Document Versions"),
pn.pane.Markdown("*No versions available for this document*"),
upload_btn,
sizing_mode='stretch_width'
)
#logger.info("versions found, creating DataFrame", versions)
# Convert versions to DataFrame with flexible field names
current_version_uid=self.document['current_version'].get('UID')
version_rows = []
for version in versions:
# Skip if not a dictionary
if not isinstance(version, dict):
continue
# Helper function to get a field with multiple possible names
def get_field(names):
for name in names:
if name in version and version[name] is not None:
return version[name]
return ""
# Extract data with fallbacks for different field names
version_row = {
'UID': get_field(['UID', 'uid', 'version_uid']),
'version_number': get_field(['version_number', 'versionNumber', 'number', 'revision']),
'is_current': current_version_uid==get_field(['UID', 'uid', 'version_uid']),
'created_date': get_field(['created_date', 'createdDate', 'date']),
'created_by_name': get_field(['created_by_name', 'createdByName', 'creatorName', 'creator']),
'file_name': get_field(['file_name', 'fileName', 'name']),
'comment': get_field(['comment', 'versionComment', 'notes'])
}
version_rows.append(version_row)
# If no valid rows, show message
if not version_rows:
return pn.Column(
pn.pane.Markdown("# Document Versions"),
pn.pane.Markdown(f"*No valid version data found*"),
sizing_mode='stretch_width'
)
# Create DataFrame
versions_df = pd.DataFrame(version_rows)
logger.debug(f"Created DataFrame with columns: {versions_df.columns.tolist()}")
# Format dates
if 'created_date' in versions_df.columns:
# Convert dates safely
try:
versions_df['created_date'] = pd.to_datetime(versions_df['created_date']).dt.strftime('%Y-%m-%d %H:%M')
except Exception as date_err:
logger.warning(f"Error formatting dates: {date_err}")
# Sort by version number if possible
try:
versions_df = versions_df.sort_values('version_number', ascending=False)
except Exception as sort_err:
logger.warning(f"Error sorting versions: {sort_err}")
# Add hidden UID column for reference
if 'UID' in versions_df.columns:
versions_df['_uid'] = versions_df['UID']
# Select columns for display
display_columns = []
column_names = {}
# Include columns that exist in the dataframe
if 'version_number' in versions_df.columns:
display_columns.append('version_number')
column_names['version_number'] = 'Version'
if 'is_current' in versions_df.columns:
display_columns.append('is_current')
column_names['is_current'] = 'Current'
if 'created_date' in versions_df.columns:
display_columns.append('created_date')
column_names['created_date'] = 'Created'
if 'created_by_name' in versions_df.columns:
display_columns.append('created_by_name')
column_names['created_by_name'] = 'Created By'
if 'file_name' in versions_df.columns:
display_columns.append('file_name')
column_names['file_name'] = 'File Name'
if 'comment' in versions_df.columns:
display_columns.append('comment')
column_names['comment'] = 'Comment'
# Add action column with button formatter
versions_df['_action'] = 'Download'
display_columns.append('_action')
column_names['_action'] = 'Action'
# Filter and rename columns
filtered_cols = [col for col in display_columns if col in versions_df.columns]
# Make sure to always include the UID columns even if they're not displayed
display_df = versions_df[filtered_cols]
# Create formatters for tabulator
formatters = {
'Current': {'type': 'tickCross'},
'Action': {
'type': 'button',
'buttonTitle': 'Download',
'buttonLabel': 'Download'
}
}
# Make sure there's a hidden UID column for reference
if 'uid' in versions_df.columns:
display_df['__uid'] = versions_df['UID'] # Double underscore to avoid conflicts
if '_uid' in versions_df.columns:
display_df['__uid'] = versions_df['_uid'] # Double underscore to avoid conflicts
# Create versions table
versions_table = Tabulator(
display_df,
pagination='local',
page_size=10,
sizing_mode='stretch_width',
selectable=1,
height=400,
formatters=formatters
)
# Add version selection handler
versions_table.on_click(self._version_selected)
# Create upload new version button if user has permission
upload_btn = Button(
name="Upload New Version",
button_type="primary",
width=150,
disabled=not permissions.user_has_permission(self.user, "CREATE_VERSION")
)
upload_btn.on_click(self._show_upload_form)
# Create version action area
version_action_area = pn.Column(
pn.pane.Markdown("## Select a version to view"),
sizing_mode='stretch_width'
)
# Store this for later reference
self._version_action_area = version_action_area
# Layout
versions_layout = pn.Column(
pn.pane.Markdown("# Document Versions"),
pn.pane.Markdown("The table below shows all versions of this document. Click on a version to view or download it."),
pn.Row(
pn.layout.HSpacer(),
upload_btn,
sizing_mode='stretch_width',
align='end'
),
versions_table,
version_action_area,
sizing_mode='stretch_width'
)
return versions_layout
def _edit_document_online(self, event=None):
"""Get edit URL from FileCloud and open it"""
# Show loading message
self.notification_area.object = "**Getting edit URL...**"
try:
# Import the document controller function
from CDocs.controllers.document_controller import get_document_edit_url
# Call API to get edit URL
result = get_document_edit_url(
user=self.user,
document_uid=self.document_uid
)
if result.get('success'):
edit_url = result.get('edit_url')
# Create a clickable link to open the edit URL
self.notification_area.object = f"""
**Document ready for editing!**
Click this link to edit the document online:
[Open in FileCloud Editor]({edit_url})
"""
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Unable to get edit URL')}"
except Exception as e:
import traceback
logger.error(f"Error getting edit URL: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error getting edit URL:** {str(e)}"
def _convert_to_pdf(self, event=None):
"""Convert the current document version to PDF"""
# Show loading message
self.notification_area.object = "**Converting document to PDF...**"
try:
# Import the document controller function
from CDocs.controllers.document_controller import convert_document_to_pdf
# Call API to convert document - FIX: Pass the user object
result = convert_document_to_pdf(
user=self.user, # Pass the user object
document_uid=self.document_uid
)
if result.get('success'):
# Show success message with PDF path
message = f"""
**Document converted to PDF successfully!**
PDF path: {result.get('pdf_path')}
Reload this page to see the updated document.
"""
# Create a new notification with both message and button
notification_column = pn.Column(
pn.pane.Markdown(message),
pn.widgets.Button(name="Reload Document", button_type="primary", on_click=self._load_document)
)
# Replace the notification area with our column
for i, item in enumerate(self.main_content):
if item is self.notification_area:
self.main_content[i] = notification_column
break
if not any(item is notification_column for item in self.main_content):
# If we couldn't find and replace, just update the message
self.notification_area.object = message
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Failed to convert document')}"
except Exception as e:
import traceback
logger.error(f"Error converting document to PDF: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error converting document:** {str(e)}"
def _create_reviews_tab(self):
"""Create the reviews tab content"""
# Get review cycles from document
review_cycles = []
try:
# Call controller to get review cycles
from CDocs.controllers.review_controller import get_document_review_cycles
review_result = get_document_review_cycles(document_uid=self.document_uid)
review_cycles = review_result.get('review_cycles', [])
logger.debug(f"Loaded {len(review_cycles)} review cycles")
except Exception as e:
logger.error(f"Error loading review cycles: {e}")
return pn.Column(
pn.pane.Markdown("# Document Reviews"),
pn.pane.Markdown(f"**Error loading review data:** {str(e)}"),
sizing_mode='stretch_width'
)
if not review_cycles:
# Create button to start review if appropriate
if self.document.get('status') == 'DRAFT' and permissions.user_has_permission(self.user, "INITIATE_REVIEW"):
start_review_btn = pn.widgets.Button(
name="Start Review",
button_type="primary",
width=150
)
start_review_btn.on_click(self._show_review_form)
return pn.Column(
pn.pane.Markdown("# Document Reviews"),
pn.pane.Markdown("*No review cycles found for this document*"),
start_review_btn,
sizing_mode='stretch_width'
)
else:
return pn.Column(
pn.pane.Markdown("# Document Reviews"),
pn.pane.Markdown("*No review cycles found for this document*"),
sizing_mode='stretch_width'
)
# Convert to DataFrame for tabulator
try:
# Create a clean list of dictionaries for the DataFrame
reviews_data = []
for cycle in review_cycles:
# Convert Neo4j DateTime objects to Python datetime objects or strings
cycle_dict = {}
for key, value in cycle.items():
# Convert Neo4j DateTime objects to strings
if hasattr(value, '__class__') and value.__class__.__name__ == 'DateTime':
# Convert Neo4j DateTime to string in ISO format
try:
cycle_dict[key] = value.iso_format()[:10] # Just get the YYYY-MM-DD part
except (AttributeError, TypeError):
cycle_dict[key] = str(value)
else:
cycle_dict[key] = value
reviews_data.append(cycle_dict)
# Create DataFrame (safely handle empty data)
if not reviews_data:
return pn.Column(
pn.pane.Markdown("# Document Reviews"),
pn.pane.Markdown("*Error: Review cycle data is in unexpected format*"),
sizing_mode='stretch_width'
)
reviews_df = pd.DataFrame(reviews_data)
except Exception as df_error:
logger.error(f"Error creating reviews DataFrame: {df_error}")
logger.error(f"Traceback: {traceback.format_exc()}")
return pn.Column(
pn.pane.Markdown("# Document Reviews"),
pn.pane.Markdown(f"*Error formatting review cycle data: {str(df_error)}*"),
sizing_mode='stretch_width'
)
# Format dates - no need to use pd.to_datetime since we already formatted them
date_columns = ['start_date', 'startDate', 'due_date', 'dueDate', 'completed_date', 'completionDate']
for col in date_columns:
if col in reviews_df.columns:
# Instead of using pd.to_datetime, ensure the column contains strings
reviews_df[col] = reviews_df[col].astype(str)
# Select and rename columns for display
display_columns = ['UID', 'cycle_number', 'status', 'initiated_by_name', 'startDate', 'dueDate', 'completionDate']
column_names = {
'cycle_number': 'Cycle #',
'status': 'Status',
'initiated_by_name': 'Initiated By',
'startDate': 'Started',
'dueDate': 'Due',
'completionDate': 'Completed'
}
# Filter columns that exist in the DataFrame
exist_columns = [col for col in display_columns if col in reviews_df.columns]
reviews_df = reviews_df[exist_columns]
# Rename columns
rename_dict = {col: column_names[col] for col in exist_columns if col in column_names}
reviews_df = reviews_df.rename(columns=rename_dict)
# Add action column
reviews_df['Action'] = 'View'
# Create reviews table
reviews_table = Tabulator(
reviews_df,
pagination='local',
page_size=5,
sizing_mode='stretch_width',
selectable=1,
height=300
)
# Add review selection handler
reviews_table.on_click(self._review_selected)
# Create review details area
review_details_area = Column(
Markdown("## Review Details"),
Markdown("*Select a review cycle to see details*"),
sizing_mode='stretch_width',
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
# Create start review button if appropriate
buttons = []
if self.document.get('status') == 'DRAFT' and permissions.user_has_permission(self.user, "INITIATE_REVIEW"):
start_review_btn = Button(
name="Start New Review Cycle",
button_type="primary",
width=180
)
start_review_btn.on_click(self._show_review_form)
buttons.append(start_review_btn)
# Layout
reviews_layout = Column(
Markdown("# Document Reviews"),
Row(*buttons, sizing_mode='stretch_width', align='end') if buttons else None,
reviews_table,
review_details_area,
sizing_mode='stretch_width'
)
return reviews_layout
def _review_selected(self, event):
"""Handle review selection from table with support for both selection and cell click events"""
try:
# Handle different event types
row_index = None
row_data = None
# Check if this is a CellClickEvent (from clicking on a table cell)
if hasattr(event, 'row') and event.row is not None:
# This is a CellClickEvent
row_index = event.row
# For CellClickEvent, extract data from event.model.source
if hasattr(event, 'model') and hasattr(event.model, 'source') and hasattr(event.model.source, 'data'):
source_data = event.model.source.data
# Create a dictionary with column name -> value for this row
row_data = {col: values[row_index] for col, values in source_data.items() if len(values) > row_index}
# Look for UID in source data
for col in source_data.keys():
if col in ['UID', 'uid', '_uid'] and len(source_data[col]) > row_index:
review_uid = source_data[col][row_index]
logger.debug(f"Found review UID from cell click: {review_uid}")
break
# Handle TabSelector selection event (event.new)
elif hasattr(event, 'new') and event.new:
row_index = event.new[0] if isinstance(event.new, list) else event.new
# Get data from the DataFrame for TabSelector events
if hasattr(event, 'obj') and hasattr(event.obj, 'value'):
df = event.obj.value
if row_index < len(df):
row_data = df.iloc[row_index].to_dict()
# Get the review UID - check different possible column names
for col in ['UID', 'uid', '_uid']:
if col in df.columns:
review_uid = df.iloc[row_index][col]
logger.debug(f"Found review UID from selection: {review_uid}")
break
# If we couldn't get row index, exit
if row_index is None:
logger.warning("No row index found in review selection event")
return
# If we don't have review_uid yet, try to extract it from row_data
if not locals().get('review_uid') and row_data:
for key in ['UID', 'uid', '_uid']:
if key in row_data:
review_uid = row_data[key]
logger.debug(f"Found review UID from row data with key {key}: {review_uid}")
break
# Exit if we still couldn't find the review UID
if not locals().get('review_uid'):
self.notification_area.object = "**Error:** Could not determine review UID"
return
# Store for later access
self._selected_review_uid = locals().get('review_uid')
# Get detailed review data from backend
from CDocs.controllers.review_controller import get_review_cycle
review_data = get_review_cycle(
review_uid=self._selected_review_uid,
include_comments=True,
include_document=True
)
if not review_data:
self.notification_area.object = "**Error:** Could not retrieve review details"
return
# Recursively convert all Neo4j DateTime objects to Python datetime objects
review_data = self._convert_neo4j_datetimes(review_data)
# Rest of the method remains the same...
# Extract reviewer assignments
reviewer_assignments = review_data.get('reviewer_assignments', [])
# Create DataFrame for reviewers
reviewer_data = []
for assignment in reviewer_assignments:
status = assignment.get('status', '')
decision = assignment.get('decision', '')
reviewer_data.append({
'reviewer_name': assignment.get('reviewer_name', ''),
'role': assignment.get('role', ''),
'status': status,
'decision': decision if status == 'COMPLETED' else '',
'assigned_date': self._format_date(assignment.get('assigned_date')),
'decision_date': self._format_date(assignment.get('decision_date')) if status == 'COMPLETED' else ''
})
reviewers_df = pd.DataFrame(reviewer_data)
# Create reviewers table
reviewer_table = pn.widgets.Tabulator(
reviewers_df,
pagination='local',
page_size=10,
sizing_mode='stretch_width',
height=200
)
# Extract review comments
comments = review_data.get('comments', [])
# Create review details
status = review_data.get('status', '')
status_color = '#28a745' if status == 'COMPLETED' else '#ffc107' if status == 'IN_PROGRESS' else '#dc3545' if status == 'CANCELED' else '#6c757d'
# Get the details of the review cycle
started_date = self._format_date(review_data.get('startDate', ''))
due_date = self._format_date(review_data.get('dueDate', ''))
completed_date = self._format_date(review_data.get('completionDate', ''))
initiated_by = review_data.get('initiated_by_name', 'Unknown')
review_type = review_data.get('review_type', 'STANDARD')
sequential = review_data.get('sequential', False)
instructions = review_data.get('instructions', '')
on_version = review_data.get('on_version', 'Unknown')
# Create content sections
review_header = pn.Column(
pn.pane.Markdown(f"## Review Cycle Details"),
pn.pane.Markdown(f"**Version:** {on_version}"),
pn.pane.Markdown(f"**Status:** <span style='color:{status_color};font-weight:bold;'>{status}</span>"),
pn.pane.Markdown(f"**Started:** {started_date}"),
pn.pane.Markdown(f"**Due Date:** {due_date}"),
pn.pane.Markdown(f"**Completed:** {completed_date if completed_date else 'Not completed'}"),
pn.pane.Markdown(f"**Initiated By:** {initiated_by}"),
pn.pane.Markdown(f"**Review Type:** {review_type}"),
pn.pane.Markdown(f"**Review Mode:** {'Sequential' if sequential else 'Parallel'}"),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
# Instructions section if they exist
instructions_section = None
if instructions:
instructions_section = pn.Column(
pn.pane.Markdown("### Instructions"),
pn.pane.Markdown(instructions),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'mt-3']
)
# Reviewers section
reviewers_section = pn.Column(
pn.pane.Markdown("### Reviewers"),
reviewer_table,
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'mt-3']
)
# Comments section if there are comments
comments_section = None
if comments:
comments_md = ["### Comments"]
for comment in comments:
user_name = comment.get('user_name', 'Unknown')
timestamp = self._format_date(comment.get('timestamp', ''))
text = comment.get('text', '')
comments_md.append(f"**{user_name}** ({timestamp}):<br>{text}<hr>")
comments_section = pn.Column(
*[pn.pane.Markdown(md) for md in comments_md],
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'mt-3']
)
# Action buttons section
action_buttons = []
# Button to extend deadline if user has permission and review is active
if permissions.user_has_permission(self.user, "MANAGE_REVIEWS") and status in ['PENDING', 'IN_PROGRESS']:
extend_btn = pn.widgets.Button(
name="Extend Deadline",
button_type="default",
width=150
)
extend_btn.on_click(lambda event: self._show_extend_review_deadline_form(self._selected_review_uid))
action_buttons.append(extend_btn)
# Button to add reviewer if user has permission and review is active
if permissions.user_has_permission(self.user, "MANAGE_REVIEWS") and status in ['PENDING', 'IN_PROGRESS']:
add_reviewer_btn = pn.widgets.Button(
name="Add Reviewer",
button_type="default",
width=150
)
add_reviewer_btn.on_click(lambda event: self._show_add_reviewer_form(self._selected_review_uid))
action_buttons.append(add_reviewer_btn)
# Button to cancel review if user has permission and review is active
if permissions.user_has_permission(self.user, "MANAGE_REVIEWS") and status in ['PENDING', 'IN_PROGRESS']:
cancel_btn = pn.widgets.Button(
name="Cancel Review",
button_type="danger",
width=150
)
cancel_btn.on_click(lambda event: self._show_cancel_review_form(self._selected_review_uid))
action_buttons.append(cancel_btn)
# Create action buttons row if any buttons
actions_section = None
if action_buttons:
actions_section = pn.Row(
*action_buttons,
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'mt-3']
)
# Compile all sections that exist
sections = [review_header, reviewers_section]
if instructions_section:
sections.append(instructions_section)
if comments_section:
sections.append(comments_section)
if actions_section:
sections.append(actions_section)
# Create final layout
review_details = pn.Column(
*sections,
sizing_mode='stretch_width'
)
# Find and update the review details area safely
reviews_tab_found = False
# Check if self.tabs is iterable before attempting to iterate
if hasattr(self.tabs, '__iter__'):
for item in self.tabs:
# Check if item is a tuple or list with at least 2 elements
if isinstance(item, (tuple, list)) and len(item) >= 2:
tab_name, tab_content = item[0], item[1]
if tab_name == "Reviews":
reviews_tab_found = True
# Found the Reviews tab
if isinstance(tab_content, pn.Column):
# Look for the review details area - it's typically the last item
for j, component in enumerate(tab_content):
if (isinstance(component, pn.Column) and
len(component) > 0 and
isinstance(component[0], pn.pane.Markdown) and
"Review Details" in component[0].object):
# Found the review details area, replace it with our new content
tab_content[j] = review_details
return
# If we didn't find a specific review details section,
# just append the new details to the column
tab_content.append(review_details)
return
# If we couldn't find the reviews tab through iteration,
# try directly accessing using the index if it's a Panel Tabs object
if not reviews_tab_found and hasattr(self.tabs, '__getitem__'):
try:
# Panel's Tabs typically use integer indices
review_tab_index = None
# Try to find the Reviews tab index
for i, name in enumerate(self.tabs._names):
if name == "Reviews":
review_tab_index = i
break
if review_tab_index is not None:
tab_content = self.tabs[review_tab_index]
if isinstance(tab_content, pn.Column):
# Look for review details area at the end
if len(tab_content) > 0:
last_item = tab_content[-1]
if (isinstance(last_item, pn.Column) and
len(last_item) > 0 and
isinstance(last_item[0], pn.pane.Markdown) and
"Review Details" in last_item[0].object):
# Replace last item
tab_content[-1] = review_details
return
# Append if not found
tab_content.append(review_details)
return
except Exception as tab_err:
logger.error(f"Error accessing tabs by index: {tab_err}")
# As a last resort, just show the details in the notification area
review_details_str = "**Review details:**\n\n"
review_details_str += f"**Status:** {review_data.get('status', 'Unknown')}\n"
review_details_str += f"**Started:** {self._format_date(review_data.get('start_date', ''))}\n"
review_details_str += f"**Due Date:** {self._format_date(review_data.get('due_date', ''))}\n"
review_details_str += f"**Reviewers:** {len(review_data.get('reviewers', []))}\n"
# Update notification area with string instead of Column object
# Update notification area with string
self.notification_area.object = review_details_str
# Create a safe shallow copy of review_details for the container
# This avoids potential circular references
review_details_md = pn.pane.Markdown("# Review Details")
# Then create a new Column in a separate row of main_content
# Use only components that we're sure can be serialized
review_details_container = pn.Column(
review_details_md,
*[pn.pane.Markdown(f"**{section}:** {value}") for section, value in {
"Status": review_data.get('status', 'Unknown'),
"Started": self._format_date(review_data.get('startDate', '')),
"Due Date": self._format_date(review_data.get('dueDate', '')),
"Instructions": review_data.get('instructions', 'None provided')
}.items()],
sizing_mode='stretch_width'
)
# Clear main_content and add both notification and simplified details container
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(review_details_container)
except Exception as e:
logger.error(f"Error selecting review: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** {str(e)}"
def _show_extend_review_deadline_form(self, review_uid):
"""Show form to extend review deadline"""
try:
# Store the review UID for later use
self._selected_review_uid = review_uid
# Get current review cycle to show current deadline
from CDocs.controllers.review_controller import get_review_cycle
review_data = get_review_cycle(review_uid)
if not review_data:
self.notification_area.object = "**Error:** Could not retrieve review cycle details"
return
# Extract current deadline for display
current_deadline = None
if 'dueDate' in review_data:
try:
current_deadline = self._convert_neo4j_datetimes(review_data['dueDate'])
current_deadline_str = current_deadline.strftime("%Y-%m-%d") if current_deadline else "Not set"
except Exception:
current_deadline_str = str(review_data['dueDate'])
else:
current_deadline_str = "Not set"
# Create form elements
date_picker = pn.widgets.DatePicker(
name="New Due Date",
value=None,
start=datetime.now().date() + timedelta(days=1), # Must be at least tomorrow
end=datetime.now().date() + timedelta(days=90), # Maximum 90 days in future
width=200
)
reason_input = pn.widgets.TextAreaInput(
name="Reason for Extension",
placeholder="Enter reason for deadline extension",
rows=3,
width=300
)
submit_btn = pn.widgets.Button(
name="Extend Deadline",
button_type="primary",
width=150
)
cancel_btn = pn.widgets.Button(
name="Cancel",
button_type="default",
width=100
)
# Create form container
form = pn.Column(
pn.pane.Markdown("## Extend Review Deadline"),
pn.pane.Markdown(f"Current deadline: **{current_deadline_str}**"),
pn.Row(date_picker, sizing_mode='stretch_width'),
pn.Row(reason_input, sizing_mode='stretch_width'),
pn.Row(
pn.layout.HSpacer(),
cancel_btn,
submit_btn,
sizing_mode='stretch_width'
),
width=400,
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'shadow']
)
# Define submission handler
def submit_extension(event):
# Validate inputs
if not date_picker.value:
self.notification_area.object = "**Error:** Please select a new deadline date"
return
# Convert to datetime with end of day
new_deadline = datetime.combine(date_picker.value, datetime.max.time())
# Call controller function
from CDocs.controllers.review_controller import extend_review_deadline
try:
result = extend_review_deadline(
user=self.user,
review_uid=review_uid,
new_due_date=new_deadline,
reason=reason_input.value
)
if result.get('success', False):
self.notification_area.object = "**Success:** Review deadline extended"
# Close form and refresh review details
# Close form and refresh document details
self._load_document()
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Unknown error')}"
except Exception as e:
self.notification_area.object = f"**Error:** {str(e)}"
# Define cancel handler
def cancel_action(event):
self.notification_area.object = "Deadline extension canceled"
# Remove the form from view
if hasattr(self, 'main_content'):
for i, item in enumerate(self.main_content):
if item is form:
self.main_content.pop(i)
break
# Add handlers
submit_btn.on_click(submit_extension)
cancel_btn.on_click(cancel_action)
# Clear display area and show form
# FIX: Check if template exists, otherwise use main_content
if self.template and hasattr(self.template, 'main'):
# Standalone mode using template
self.tabs.clear()
self.template.main.clear()
self.template.main.append(self.notification_area)
self.template.main.append(form)
else:
# Embedded mode using main_content
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(form)
except Exception as e:
logger.error(f"Error showing extend deadline form: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** {str(e)}"
def _show_add_reviewer_form(self, review_uid):
"""Show form to add a reviewer to an active review cycle"""
try:
# Store the review UID for later use
self._selected_review_uid = review_uid
# Get current review cycle
from CDocs.controllers.review_controller import get_review_cycle
review_data = get_review_cycle(review_uid)
if not review_data:
self.notification_area.object = "**Error:** Could not retrieve review cycle details"
return
# Get potential reviewers (exclude current reviewers)
from CDocs.models.user_extensions import DocUser
try:
potential_reviewers = DocUser.get_users_by_role(role="REVIEWER")
# Get current reviewer UIDs to exclude
current_reviewer_uids = set()
for reviewer in review_data.get('reviewers', []):
current_reviewer_uids.add(reviewer.get('UID'))
# Filter out current reviewers
potential_reviewers = [u for u in potential_reviewers if u.uid not in current_reviewer_uids]
# Create options dictionary for select widget
user_options = {f"{u.name} ({u.username})" : u.uid for u in potential_reviewers}
# If no potential reviewers, show message
if not user_options:
self.notification_area.object = "**Info:** No additional reviewers available"
return
except Exception as users_err:
# Fallback - get all users
logger.error(f"Error getting potential reviewers: {users_err}")
user_options = {"user1": "User 1", "user2": "User 2"} # Placeholder
# Create form elements
reviewer_select = pn.widgets.Select(
name="Select Reviewer",
options=user_options,
width=300
)
# For sequential reviews, add sequence selector
sequence_input = None
if review_data.get('sequential', False):
sequence_input = pn.widgets.IntInput(
name="Review Sequence Order",
value=len(review_data.get('reviewers', [])) + 1, # Default to next in sequence
start=1,
width=150
)
submit_btn = pn.widgets.Button(
name="Add Reviewer",
button_type="primary",
width=150
)
cancel_btn = pn.widgets.Button(
name="Cancel",
button_type="default",
width=100
)
# Create form container
form_components = [
pn.pane.Markdown("## Add Reviewer to Review Cycle"),
pn.pane.Markdown(f"Review: {review_data.get('status', 'Unknown')}"),
pn.Row(reviewer_select, sizing_mode='stretch_width')
]
if sequence_input:
form_components.append(pn.Row(sequence_input, sizing_mode='stretch_width'))
form_components.append(
pn.Row(
pn.layout.HSpacer(),
cancel_btn,
submit_btn,
sizing_mode='stretch_width'
)
)
form = pn.Column(
*form_components,
width=400,
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'shadow']
)
# Define submission handler
def submit_add_reviewer(event):
# Validate inputs
if not reviewer_select.value:
self.notification_area.object = "**Error:** Please select a reviewer"
return
# Call controller function
from CDocs.controllers.review_controller import add_reviewer_to_active_review
try:
result = add_reviewer_to_active_review(
user=self.user,
review_uid=review_uid,
reviewer_uid=reviewer_select.value,
sequence_order=sequence_input.value if sequence_input else None
)
if result.get('success', False):
self.notification_area.object = "**Success:** Reviewer added to review cycle"
# Close form and refresh review details
# Close form and refresh document details
self._load_document()
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Unknown error')}"
except Exception as e:
self.notification_area.object = f"**Error:** {str(e)}"
# Define cancel handler
def cancel_action(event):
self.notification_area.object = "Reviewer addition canceled"
# Remove the form from view
if hasattr(self, 'main_content'):
for i, item in enumerate(self.main_content):
if item is form:
self.main_content.pop(i)
break
# Add handlers
submit_btn.on_click(submit_add_reviewer)
cancel_btn.on_click(cancel_action)
# Clear display area and show form
# FIX: Check if template exists, otherwise use main_content
if self.template and hasattr(self.template, 'main'):
# Standalone mode using template
self.tabs.clear()
self.template.main.clear()
self.template.main.append(self.notification_area)
self.template.main.append(form)
else:
# Embedded mode using main_content
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(form)
except Exception as e:
logger.error(f"Error showing add reviewer form: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** {str(e)}"
def _show_cancel_review_form(self, review_uid):
"""Show form to cancel an active review cycle"""
try:
# Store the review UID for later use
self._selected_review_uid = review_uid
# Get current review cycle
from CDocs.controllers.review_controller import get_review_cycle
review_data = get_review_cycle(review_uid, include_document=True)
if not review_data:
self.notification_area.object = "**Error:** Could not retrieve review cycle details"
return
# Create form elements
reason_input = pn.widgets.TextAreaInput(
name="Reason for Cancellation",
placeholder="Enter reason for canceling the review cycle",
rows=3,
width=300
)
confirm_checkbox = pn.widgets.Checkbox(
name="I understand this action cannot be undone",
value=False
)
submit_btn = pn.widgets.Button(
name="Cancel Review",
button_type="danger",
width=150,
disabled=True # Disabled until checkbox is checked
)
cancel_btn = pn.widgets.Button(
name="Go Back",
button_type="default",
width=100
)
# Create warning with document info
document_info = review_data.get('document', {})
doc_number = document_info.get('doc_number', 'Unknown')
doc_title = document_info.get('title', 'Unknown')
warning_md = pn.pane.Markdown(f"""
## ⚠️ Cancel Review Cycle
**Warning:** You are about to cancel the active review cycle for document:
**{doc_number}**: {doc_title}
This action cannot be undone. All reviewer assignments and comments will remain,
but the review cycle will be marked as canceled.
""")
# Create form container
form = pn.Column(
warning_md,
pn.Row(reason_input, sizing_mode='stretch_width'),
pn.Row(confirm_checkbox, sizing_mode='stretch_width'),
pn.Row(
pn.layout.HSpacer(),
cancel_btn,
submit_btn,
sizing_mode='stretch_width'
),
width=500,
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'shadow']
)
# Enable/disable submit button based on checkbox
def update_submit_btn(event):
submit_btn.disabled = not confirm_checkbox.value
confirm_checkbox.param.watch(update_submit_btn, 'value')
# Define submission handler
def submit_cancel_review(event):
# Validate inputs
if not reason_input.value or len(reason_input.value.strip()) < 10:
self.notification_area.object = "**Error:** Please provide a detailed reason for cancellation"
return
if not confirm_checkbox.value:
self.notification_area.object = "**Error:** You must confirm the action"
return
# Call controller function
from CDocs.controllers.review_controller import cancel_review_cycle
try:
result = cancel_review_cycle(
user=self.user,
review_uid=review_uid,
reason=reason_input.value
)
if result.get('success', False):
self.notification_area.object = "**Success:** Review cycle canceled successfully"
# Close form and refresh document details
self._load_document()
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Unknown error')}"
except Exception as e:
self.notification_area.object = f"**Error:** {str(e)}"
# Define cancel handler
def cancel_action(event):
self.notification_area.object = "Cancellation aborted"
# Remove the form from view
if hasattr(self, 'main_content'):
for i, item in enumerate(self.main_content):
if item is form:
self.main_content.pop(i)
break
# Add handlers
submit_btn.on_click(submit_cancel_review)
cancel_btn.on_click(cancel_action)
# Clear display area and show form
# FIX: Check if template exists, otherwise use main_content
if self.template and hasattr(self.template, 'main'):
# Standalone mode using template
self.tabs.clear()
self.template.main.clear()
self.template.main.append(self.notification_area)
self.template.main.append(form)
else:
# Embedded mode using main_content
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(form)
except Exception as e:
logger.error(f"Error showing cancel review form: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** {str(e)}"
def _load_review_details_by_uid(self, review_uid):
"""Helper method to refresh review details after an action"""
try:
# Just re-trigger the review selection with the same review UID
# This will fetch fresh data and update the UI
class DummyEvent:
pass
dummy_event = DummyEvent()
dummy_event.new = [0] # Simulate first row selection
# Store review UID so _review_selected can find it
self._selected_review_uid = review_uid
# Re-trigger the review selection handler
self._review_selected(dummy_event)
except Exception as e:
logger.error(f"Error refreshing review details: {e}")
self.notification_area.object = f"**Error refreshing review details:** {str(e)}"
def _convert_neo4j_datetimes(self, data):
"""
Recursively convert all Neo4j DateTime objects to Python datetime objects or strings.
Args:
data: Any data structure potentially containing Neo4j DateTime objects
Returns:
Same data structure with Neo4j DateTime objects converted to Python datetime
"""
if data is None:
return None
# Handle Neo4j DateTime objects
if hasattr(data, '__class__') and data.__class__.__name__ == 'DateTime':
try:
# Try to convert to Python datetime
import datetime
py_datetime = datetime.datetime(
year=data.year,
month=data.month,
day=data.day,
hour=data.hour,
minute=data.minute,
second=data.second,
microsecond=data.nanosecond // 1000
)
return py_datetime
except (AttributeError, ValueError):
# If conversion fails, return as string
return str(data)
# Handle dictionaries
elif isinstance(data, dict):
return {k: self._convert_neo4j_datetimes(v) for k, v in data.items()}
# Handle lists
elif isinstance(data, list):
return [self._convert_neo4j_datetimes(item) for item in data]
# Handle tuples
elif isinstance(data, tuple):
return tuple(self._convert_neo4j_datetimes(item) for item in data)
# Return other types unchanged
return data
def _create_approvals_tab(self):
"""Create the approvals tab content with support for the new approval schema"""
# Main container
approvals_tab = pn.Column(sizing_mode='stretch_width')
approvals_tab.append(pn.pane.Markdown("## Document Approvals"))
try:
# Get approvals for this document
from CDocs.controllers.approval_controller import get_document_approvals
result = get_document_approvals(self.document_uid)
if not result or 'approvals' not in result or not result['approvals']:
approvals_tab.append(pn.pane.Markdown("*No approvals found for this document*"))
return approvals_tab
approvals = result['approvals']
# Sort approvals by date, newest first
approvals.sort(key=lambda x: x.get('initiated_date', ''), reverse=True)
# Create approval cards
for approval in approvals:
# Extract data with fallbacks
status = approval.get('status', 'UNKNOWN')
workflow_type = approval.get('workflow_type', 'Standard')
initiated_date = self._format_date(approval.get('initiated_date'))
due_date = self._format_date(approval.get('due_date'))
completion_date = self._format_date(approval.get('completion_date', ''))
# Get approval steps
steps = approval.get('steps', [])
# Calculate completion stats
steps_completed = sum(1 for step in steps if step.get('status') == 'COMPLETED')
steps_total = len(steps)
steps_percentage = int((steps_completed / steps_total) * 100) if steps_total > 0 else 0
# Create approval card header
header = pn.Row(
pn.pane.Markdown(f"### {workflow_type} Approval"),
pn.pane.Markdown(f"<span style='color:{self._get_status_color(status)};'>Status: {status}</span>", sizing_mode='stretch_width'),
pn.layout.HSpacer(),
pn.widgets.Button(name="View Details", button_type="primary", width=120,
on_click=lambda e, uid=approval.get('UID'): self._view_approval_details(uid)),
sizing_mode='stretch_width'
)
# Create approval card body
body = pn.Column(
pn.pane.Markdown(f"**Initiated:** {initiated_date}"),
pn.pane.Markdown(f"**Due Date:** {due_date}"),
pn.pane.Markdown(f"**Completion Date:** {completion_date if completion_date else 'Not completed'}"),
pn.pane.Markdown(f"**Progress:** {steps_completed} of {steps_total} steps completed ({steps_percentage}%)"),
sizing_mode='stretch_width'
)
# Create step progress section
steps_section = pn.Column(
pn.pane.Markdown("#### Approval Steps"),
sizing_mode='stretch_width'
)
for i, step in enumerate(steps, 1):
step_status = step.get('status', 'PENDING')
step_card = self._create_step_card(step, i)
steps_section.append(step_card)
# Create the complete card
card = pn.Column(
header,
pn.layout.Divider(),
body,
pn.layout.Divider(),
steps_section,
styles={'background': '#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'mb-4'],
sizing_mode='stretch_width'
)
approvals_tab.append(card)
except Exception as e:
logger.error(f"Error creating approvals tab: {e}")
logger.error(traceback.format_exc())
approvals_tab.append(pn.pane.Markdown(f"**Error loading approvals:** {str(e)}"))
return approvals_tab
def _create_step_card(self, step, step_number):
"""Create a card for an approval step"""
# Extract data
status = step.get('status', 'PENDING')
step_type = step.get('step_type', 'Standard')
completion_date = self._format_date(step.get('completion_date', ''))
# Get approvers
approvers = step.get('approvers', [])
# Create the step card
step_card = pn.Column(
pn.Row(
pn.pane.Markdown(f"**Step {step_number}:** {step_type}"),
pn.layout.HSpacer(),
pn.pane.Markdown(f"<span style='color:{self._get_status_color(status)};'>Status: {status}</span>"),
sizing_mode='stretch_width'
),
sizing_mode='stretch_width',
styles={'background': '#ffffff'},
css_classes=['p-2', 'border', 'rounded', 'mb-2']
)
# Add approvers information
approvers_list = pn.Column(sizing_mode='stretch_width')
for approver in approvers:
approver_name = approver.get('approver_name', 'Unknown')
approver_status = approver.get('status', 'PENDING')
decision = approver.get('decision', '')
decision_date = self._format_date(approver.get('decision_date', ''))
approver_item = pn.Row(
pn.pane.Markdown(f"**{approver_name}**"),
pn.layout.HSpacer(),
pn.pane.Markdown(f"<span style='color:{self._get_status_color(approver_status)};'>{approver_status}</span>"),
pn.pane.Markdown(f"{decision + ' on ' + decision_date if decision and decision_date else ''}"),
sizing_mode='stretch_width'
)
approvers_list.append(approver_item)
step_card.append(approvers_list)
return step_card
def _get_status_color(self, status):
"""Get color for a status value"""
# Default colors for various statuses
status_colors = {
'PENDING': '#6c757d', # Gray
'IN_PROGRESS': '#ffc107', # Yellow
'COMPLETED': '#28a745', # Green
'APPROVED': '#28a745', # Green
'REJECTED': '#dc3545', # Red
'CANCELED': '#6c757d' # Gray
}
# Look up in settings if available
if hasattr(settings, 'APPROVAL_STATUS_CONFIG'):
status_config = getattr(settings, 'APPROVAL_STATUS_CONFIG', {}).get(status, {})
if status_config and 'color' in status_config:
return status_config['color']
# Fallback to our local mapping
return status_colors.get(status, '#6c757d') # Default gray if not found
def _view_approval_details(self, approval_uid):
"""View detailed information about an approval workflow"""
try:
# Get the full approval panel from approval_panel module
from CDocs.ui.approval_panel import create_approval_panel
# Create an embedded version of the panel
approval_panel = create_approval_panel(
session_manager=self.session_manager,
parent_app=self.parent_app,
embedded=True
)
# Set user
if self.user:
approval_panel.set_user(self.user)
# Load the specific approval
approval_panel._load_approval(approval_uid)
# Show in main content
self.main_content.clear()
self.main_content.append(pn.Row(
pn.widgets.Button(
name='← Back',
button_type='default',
width=100,
on_click=lambda e: self._load_document()
),
sizing_mode='stretch_width'
))
self.main_content.append(approval_panel.get_approval_view())
except Exception as e:
logger.error(f"Error viewing approval details: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"❌ Error loading approval details: {str(e)}"
def _create_audit_tab(self):
"""Create the audit trail tab content"""
# Get audit trail from document
audit_trail = []
try:
# Import the utilities for audit trail
from CDocs.utils.audit_trail import get_document_history
# Fetch document history/audit trail
audit_trail = get_document_history(self.document_uid)
# If no audit trail events were found, check if it's in the document object
if not audit_trail and self.document and isinstance(self.document, dict):
audit_trail = self.document.get('audit_trail', [])
logger.debug(f"Fetched {len(audit_trail)} audit trail events")
except Exception as e:
logger.error(f"Error fetching audit trail: {e}")
import traceback
logger.error(traceback.format_exc())
return Column(
Markdown("# Document Audit Trail"),
Markdown(f"**Error loading audit trail:** {str(e)}"),
sizing_mode='stretch_width'
)
if not audit_trail:
return Column(
Markdown("# Document Audit Trail"),
Markdown("*No audit trail events found for this document*"),
sizing_mode='stretch_width'
)
# Ensure audit_trail is a list of dictionaries
if not isinstance(audit_trail, list):
audit_trail = [audit_trail] if audit_trail else []
# Convert to DataFrame for tabulator
try:
# Create a clean list of dictionaries for the DataFrame
audit_data = []
for event in audit_trail:
if isinstance(event, dict):
# Ensure timestamp exists and is properly formatted
timestamp = event.get('timestamp', '')
if timestamp:
# Try to parse the timestamp to ensure it's valid
try:
if isinstance(timestamp, datetime):
formatted_time = timestamp.strftime('%Y-%m-%d %H:%M')
else:
# Try to parse it as ISO format
dt = pd.to_datetime(timestamp)
formatted_time = dt.strftime('%Y-%m-%d %H:%M')
except:
formatted_time = str(timestamp) # Fallback to string representation
else:
formatted_time = ''
# Extract key information with pre-formatted timestamp
event_data = {
'timestamp': formatted_time, # Use pre-formatted timestamp
'eventType': event.get('eventType', event.get('event_type', 'Unknown')),
'userName': event.get('userName', event.get('user_name', '')),
'description': event.get('description', ''),
'details': str(event.get('details', ''))
}
audit_data.append(event_data)
# Create DataFrame
if audit_data:
audit_df = pd.DataFrame(audit_data)
# No need for timestamp conversion as we pre-formatted the timestamps
# Select and rename columns for display
display_columns = ['timestamp', 'eventType', 'userName', 'description', 'details']
column_names = {
'timestamp': 'Time',
'eventType': 'Event Type',
'userName': 'User',
'description': 'Description',
'details': 'Details'
}
# Filter columns that exist in the DataFrame
exist_columns = [col for col in display_columns if col in audit_df.columns]
audit_df = audit_df[exist_columns]
# Rename columns
rename_dict = {col: column_names[col] for col in exist_columns if col in column_names}
audit_df = audit_df.rename(columns=rename_dict)
# Sort by timestamp if it exists
if 'Time' in audit_df.columns:
audit_df = audit_df.sort_values('Time', ascending=False)
# Create audit table
audit_table = Tabulator(
audit_df,
pagination='local',
page_size=20,
sizing_mode='stretch_width',
height=600,
show_index=False
)
# Layout
audit_layout = Column(
Markdown("# Document Audit Trail"),
Markdown("The table below shows the complete history of actions performed on this document."),
audit_table,
sizing_mode='stretch_width'
)
return audit_layout
else:
return Column(
Markdown("# Document Audit Trail"),
Markdown("*No valid audit trail events found for this document*"),
sizing_mode='stretch_width'
)
except Exception as df_error:
logger.error(f"Error creating audit trail DataFrame: {df_error}")
import traceback
logger.error(traceback.format_exc())
return Column(
Markdown("# Document Audit Trail"),
Markdown(f"**Error formatting audit trail data:** {str(df_error)}"),
sizing_mode='stretch_width'
)
def _create_document_viewer(self):
"""Create a document viewer for the current version"""
if not self.current_version:
return Markdown("*No document version available*")
# Document type
file_name = self.current_version.get('file_name', '')
file_type = file_name.split('.')[-1].lower() if '.' in file_name else ''
# For PDF, use iframe
if file_type == 'pdf':
# Get document content
try:
version_uid = self.current_version.get('UID')
doc_content = download_document_version(
user=self.user,
document_uid=self.document_uid,
version_uid=version_uid
)
if (doc_content and 'content' in doc_content) or isinstance(doc_content, bytes):
# Convert content to base64
content_b64 = base64.b64encode(doc_content['content'] if 'content' in doc_content else doc_content).decode('utf-8')
# Create data URL
data_url = f"data:application/pdf;base64,{content_b64}"
# Create iframe HTML
iframe_html = f"""
<iframe src="{data_url}" width="100%" height="600px" style="border: 1px solid #ddd;"></iframe>
"""
return HTML(iframe_html)
else:
return Markdown("*Error loading document content*")
except Exception as e:
logger.error(f"Error creating PDF viewer: {e}")
return Markdown("*Error creating document viewer*")
# For images
elif file_type in ['png', 'jpg', 'jpeg', 'gif']:
try:
version_uid = self.current_version.get('UID')
doc_content = download_document_version(
user=self.user,
document_uid=self.document_uid,
version_uid=version_uid
)
if doc_content and 'content' in doc_content:
# Convert content to base64
content_b64 = base64.b64encode(doc_content['content']).decode('utf-8')
# Create data URL
data_url = f"data:image/{file_type};base64,{content_b64}"
# Create image HTML
img_html = f"""
<img src="{data_url}" style="max-width: 100%; max-height: 600px; border: 1px solid #ddd;">
"""
return HTML(img_html)
else:
return Markdown("*Error loading image content*")
except Exception as e:
logger.error(f"Error creating image viewer: {e}")
return Markdown("*Error creating document viewer*")
# For other file types, just show download link
else:
download_btn = Button(
name=f"Download {file_name}",
button_type="primary",
width=200
)
download_btn.on_click(self._download_current_version)
return Column(
Markdown(f"Document type **{file_type}** cannot be previewed in the browser."),
download_btn
)
def _version_selected(self, event):
"""Handle version selection from table"""
logger = logging.getLogger('CDocs.ui.document_detail')
try:
# Handle different event types
row_index = None
row_data = None
# Debug the event type
logger.debug(f"Event type: {type(event).__name__}")
# Check if this is a CellClickEvent (from clicking on Action column)
if hasattr(event, 'row') and event.row is not None:
logger.debug(f"Cell click event detected for row: {event.row}")
row_index = event.row
# Store this early so we don't lose it
if hasattr(event, 'column'):
logger.debug(f"Column: {event.column}")
# For CellClickEvent, extract row data directly from the event model
if hasattr(event, 'model') and hasattr(event.model, 'source') and hasattr(event.model.source, 'data'):
source_data = event.model.source.data
logger.debug(f"Source data keys: {list(source_data.keys())}")
# Extract the row data directly from source data
# Each key in source_data is a column, with a list of values
try:
# Create a dictionary with column name -> value for this row
row_data = {col: values[row_index] for col, values in source_data.items() if len(values) > row_index}
logger.debug(f"Extracted row data directly: {row_data}")
# LOOK FOR UID SPECIFICALLY
# The UID might be in index or hidden columns not directly visible
for col, values in source_data.items():
if col.lower().endswith('UID') or col == '_uid' or col == 'UID' or col == '__uid':
if len(values) > row_index:
logger.debug(f"Found potential UID column '{col}': {values[row_index]}")
# Store this directly in case it's not included in the regular row data
if values[row_index]: # Only if not empty
self._selected_version_uid = values[row_index]
logger.debug(f"Directly stored version UID: {self._selected_version_uid}")
except Exception as extract_err:
logger.error(f"Error extracting row data: {extract_err}")
# Even for CellClickEvent, try to find UID from the actual table
# This is the part that needs safer handling
if hasattr(self, 'tabs') and len(self.tabs) > 1:
try:
versions_tab = self.tabs[1][1]
# Check if versions_tab is a container before iterating
if hasattr(versions_tab, 'objects'):
# Look for Tabulator in the versions tab
for obj in versions_tab.objects:
if isinstance(obj, pn.widgets.Tabulator):
if hasattr(obj, 'value'):
df = obj.value
logger.debug(f"Found table in versions tab with {len(df)} rows")
# If we have a valid row index and DataFrame, get the UID
if row_index < len(df):
for uid_col in ['_uid', 'UID', '__uid']:
if uid_col in df.columns:
self._selected_version_uid = df.iloc[row_index][uid_col]
logger.debug(f"Found UID in table column '{uid_col}': {self._selected_version_uid}")
break
else:
logger.debug(f"Versions tab is not a container: {type(versions_tab).__name__}")
except Exception as tab_err:
logger.error(f"Error searching for table in versions tab: {tab_err}")
# Handle TabSelector selection event format (event.new)
elif hasattr(event, 'new') and event.new:
row_index = event.new[0] if isinstance(event.new, list) else event.new
logger.debug(f"Selection event detected for row: {row_index}")
# For regular events, the table is in event.obj
if hasattr(event, 'obj') and hasattr(event.obj, 'value'):
df = event.obj.value
logger.debug(f"Using DataFrame from event.obj.value with {len(df)} rows")
if row_index < len(df):
row_data = df.iloc[row_index].to_dict()
logger.debug(f"Row data from DataFrame: {row_data}")
# Look for UID column
for uid_col in ['_uid', 'UID', '__uid']:
if uid_col in df.columns:
self._selected_version_uid = df.iloc[row_index][uid_col]
logger.debug(f"Found UID in DataFrame column '{uid_col}': {self._selected_version_uid}")
break
# Exit if no row index found
if row_index is None:
logger.warning("No row index found in event")
return
# If we still don't have row_data, try to find it
if row_data is None:
logger.warning("No row data extracted, searching for DataFrame")
df = None
# First try to get the DataFrame from the event directly
if hasattr(event, 'obj') and hasattr(event.obj, 'value'):
df = event.obj.value
logger.debug(f"Got DataFrame from event.obj.value")
elif hasattr(event, 'model') and hasattr(event.model, 'data'):
# Try to convert model data to DataFrame
try:
import pandas as pd
source_data = event.model.source.data
df = pd.DataFrame(source_data)
logger.debug(f"Created DataFrame from model.source.data")
except Exception as df_err:
logger.error(f"Error creating DataFrame from model data: {df_err}")
# If still no DataFrame, find the versions table in the versions tab
if df is None and hasattr(self, 'tabs') and len(self.tabs) > 1:
try:
versions_tab = self.tabs[1][1]
# Check if versions_tab is a container before iterating
if hasattr(versions_tab, 'objects'):
# Look for Tabulator in the versions tab objects
for obj in versions_tab.objects:
if isinstance(obj, pn.widgets.Tabulator):
if hasattr(obj, 'value'):
df = obj.value
logger.debug(f"Found table in versions tab with {len(df)} rows and columns: {df.columns.tolist()}")
break
elif isinstance(versions_tab, pn.widgets.Tabulator):
# The tab itself might be a Tabulator
if hasattr(versions_tab, 'value'):
df = versions_tab.value
logger.debug(f"Tab itself is a Tabulator with {len(df)} rows")
else:
logger.debug(f"Versions tab is not a container or Tabulator: {type(versions_tab).__name__}")
except Exception as tab_err:
logger.error(f"Error searching for table in versions tab: {tab_err}")
# If we found a DataFrame and the row index is valid, extract row data
if df is not None and row_index < len(df):
row_data = df.iloc[row_index].to_dict()
logger.debug(f"Retrieved row data from DataFrame: {row_data}")
# Look for UID column again
for uid_col in ['_uid', 'UID', '__uid']:
if uid_col in df.columns:
self._selected_version_uid = df.iloc[row_index][uid_col]
logger.debug(f"Found UID in DataFrame column '{uid_col}': {self._selected_version_uid}")
break
# Get version UID from row_data if we still don't have it
if not hasattr(self, '_selected_version_uid') or not self._selected_version_uid:
if row_data:
# Look for UID in the row data with different possible keys
uid_keys = ['_uid', 'uid', 'UID', 'version_uid', 'versionUID', '__uid']
for key in uid_keys:
if key in row_data and row_data[key]:
self._selected_version_uid = row_data[key]
logger.debug(f"Found UID in row_data with key {key}: {self._selected_version_uid}")
break
# If still not found, check if any key ends with 'uid'
if not hasattr(self, '_selected_version_uid') or not self._selected_version_uid:
for key in row_data.keys():
if key.lower().endswith('UID'):
self._selected_version_uid = row_data[key]
logger.debug(f"Found UID in row_data with key ending with 'uid': {self._selected_version_uid}")
break
# Exit if no row data found
if row_data is None:
logger.error("Could not extract row data from event")
self.notification_area.object = "**Error:** Could not access version data"
return
# Extract version information directly from row_data
logger.debug(f"Final row data keys: {list(row_data.keys())}")
# Extract version UID from row data - try different column names
if not hasattr(self, '_selected_version_uid') or not self._selected_version_uid:
version_uid = None
# Try different possible locations for the version UID
uid_variants = ['_uid', 'uid', 'UID', 'version_uid', 'versionUID', '__uid']
for key in uid_variants:
if key in row_data and row_data[key]:
version_uid = row_data[key]
break
# If still not found, check if there's a key ending with 'uid' (case insensitive)
if not version_uid:
for key in row_data.keys():
if key.lower().endswith('UID'):
version_uid = row_data[key]
break
logger.debug(f"Selected version UID: {version_uid}")
# IMPORTANT: Store the version UID in the class instance for action buttons
if version_uid:
self._selected_version_uid = version_uid
logger.debug(f"Stored version UID (from row data): {self._selected_version_uid}")
# Final check - do we have a valid UID?
if hasattr(self, '_selected_version_uid') and self._selected_version_uid:
logger.debug(f"Final selected version UID: {self._selected_version_uid}")
else:
logger.warning("No version UID found after exhaustive search")
self.notification_area.object = "**Error:** Could not determine version UID"
return
# Extract other information from row data
# Handle potential column name variations based on renaming
version_number = row_data.get('Version', row_data.get('version_number', 'Unknown'))
created_date = row_data.get('Created', row_data.get('created_date', 'Unknown'))
file_name = row_data.get('File Name', row_data.get('file_name', 'Unknown'))
# Create download button
download_btn = Button(
name="Download Version",
button_type="primary",
width=150
)
# Set up click handler for download button
download_btn.on_click(self._download_selected_version)
# Create "Set as Current" button if user has permission
set_current_btn = None
if hasattr(self.user, 'has_permission') and self.user.has_permission("MANAGE_VERSIONS"):
set_current_btn = Button(
name="Set as Current Version",
button_type="success",
width=200
)
set_current_btn.on_click(self._set_as_current_version)
# Create content for the version action area
buttons_row = pn.Row(download_btn)
if set_current_btn:
buttons_row.append(set_current_btn)
# Display the version UID in debug mode for verification
version_info = [
pn.pane.Markdown(f"## Version {version_number}"),
pn.pane.Markdown(f"Created: {created_date}"),
pn.pane.Markdown(f"File: {file_name}")
]
# Always add UID for easier debugging but only show in debug mode
try:
from CDocs.config import settings
if getattr(settings, 'DEBUG', False):
version_info.append(pn.pane.Markdown(f"UID: {self._selected_version_uid}"))
except Exception as settings_err:
logger.error(f"Error accessing settings: {settings_err}")
version_action_area = pn.Column(
*version_info,
buttons_row,
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
sizing_mode='stretch_width'
)
# Safely update the version action area
if hasattr(self, '_version_action_area'):
try:
self._version_action_area.objects = version_action_area.objects
except Exception as update_err:
logger.error(f"Error updating _version_action_area: {update_err}")
# Try a full replacement
if hasattr(self, 'tabs') and len(self.tabs) > 1:
versions_tab = self.tabs[1][1]
if isinstance(versions_tab, pn.Column) and len(versions_tab) > 0:
try:
# Replace the last element
versions_tab[-1] = version_action_area
except Exception as replace_err:
logger.error(f"Error replacing version action area: {replace_err}")
else:
# Try to find the action area in the versions tab
if hasattr(self, 'tabs') and len(self.tabs) > 1:
versions_tab = self.tabs[1][1]
if isinstance(versions_tab, pn.Column) and len(versions_tab) > 0:
try:
# Replace the last element
versions_tab[-1] = version_action_area
except Exception as replace_err:
logger.error(f"Error replacing version action area: {replace_err}")
except Exception as e:
logger.error(f"Error selecting version: {str(e)}")
import traceback
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** {str(e)}"
def _approval_selected(self, event):
"""Handle approval selection from table"""
if not event.new:
return
try:
# Get selected row index
selected_idx = event.new[0]
# Get data from the DataFrame
df = event.obj.value
# Create placeholder approval details
approval_details = Column(
Markdown("## Selected Approval Workflow"),
Markdown(f"Type: {df.iloc[selected_idx]['Type']}"),
Markdown(f"Status: {df.iloc[selected_idx]['Status']}"),
Markdown(f"Started: {df.iloc[selected_idx]['Started']}"),
Markdown(f"Due: {df.iloc[selected_idx]['Due']}"),
Markdown("### Approvers"),
Markdown("*Approver information would be displayed here*"),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
sizing_mode='stretch_width'
)
# Replace existing details area
self.tabs[3][1].objects[-1] = approval_details
except Exception as e:
logger.error(f"Error selecting approval: {e}")
self.notification_area.object = f"**Error:** {str(e)}"
def _navigate_back(self, event=None):
"""Navigate back to dashboard"""
return pn.state.execute("window.location.href = '/dashboard'")
def _view_document(self, event=None):
"""View the current document version"""
if not self.current_version:
self.notification_area.object = "**Error:** No document version available"
return
# Show loading message
self.notification_area.object = "**Getting document URL...**"
try:
# Import the document controller function
from CDocs.controllers.document_controller import get_document_edit_url
# Call API to get download URL
result = get_document_edit_url(
user=self.user,
document_uid=self.document_uid
)
if result.get('success'):
download_url = result.get('edit_url')
file_type = result.get('file_type', 'Document')
# Create a clickable link to open the document
self.notification_area.object = f"""
**Document ready for viewing!**
Click this link to view the {file_type}:
[Open in FileCloud Viewer]({download_url})
This link will expire in 24 hours.
"""
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Unable to get document URL')}"
except Exception as e:
import traceback
logger.error(f"Error viewing document: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error viewing document:** {str(e)}"
def _download_current_version(self, event=None):
"""Download the current document version"""
if not self.current_version:
self.notification_area.object = "**Error:** No document version available"
return
try:
# Get version UID
version_uid = self.current_version.get('UID')
# Import required modules
import io
from panel.widgets import FileDownload
from CDocs.controllers.document_controller import download_document_version
# Create a callback function that will fetch the file when the download button is clicked
def get_file_content():
try:
# Get document content with all required parameters
doc_content = download_document_version(
user=self.user,
document_uid=self.document_uid,
version_uid=version_uid
)
if isinstance(doc_content, dict) and 'content' in doc_content and doc_content['content']:
# Return the binary content and filename
file_name = doc_content.get('file_name', 'document.pdf')
return io.BytesIO(doc_content['content']), file_name
else:
# Handle error
self.notification_area.object = "**Error:** Could not download document"
return None, None
except Exception as e:
logger.error(f"Error downloading document: {e}")
self.notification_area.object = f"**Error:** {str(e)}"
return None, None
# Get file name from current version
file_name = self.current_version.get('file_name', 'document.pdf')
# Create download widget
download_widget = FileDownload(
callback=get_file_content,
filename=file_name,
button_type="success",
label=f"Download {file_name}"
)
# Show the download widget in the notification area
self.notification_area.object = pn.Column(
"**Document ready for download:**",
download_widget
)
except Exception as e:
logger.error(f"Error setting up download: {e}")
self.notification_area.object = f"**Error:** {str(e)}"
def _show_edit_form(self, event=None):
"""Show form to edit document metadata"""
self.notification_area.object = "Loading edit form..."
try:
# Create edit form with current values
title_input = TextInput(
name="Title",
value=self.doc_title or self.document.get('title', ''),
width=400
)
description_input = TextAreaInput(
name="Description",
value=self.document.get('description', ''),
width=400,
height=150
)
# Create save button
save_btn = Button(
name="Save Changes",
button_type="success",
width=120
)
# Create cancel button
cancel_btn = Button(
name="Cancel",
button_type="default",
width=120
)
# Create form layout
edit_form = Column(
Markdown("# Edit Document Metadata"),
title_input,
description_input,
Row(
cancel_btn,
save_btn,
align='end'
),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
width=450
)
# Set up event handlers
save_btn.on_click(lambda event: self._save_document_changes(
title_input.value,
description_input.value
))
cancel_btn.on_click(self._load_document)
# Clear display area and show form
# FIX: Check if template exists, otherwise use main_content
if self.template and hasattr(self.template, 'main'):
# Standalone mode using template
self.tabs.clear()
self.template.main.clear()
self.template.main.append(self.notification_area)
self.template.main.append(edit_form)
else:
# Embedded mode using main_content
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(edit_form)
# Clear notification
self.notification_area.object = ""
except Exception as e:
logger.error(f"Error showing edit form: {e}")
self.notification_area.object = f"**Error:** {str(e)}"
def _save_document_changes(self, title, description):
"""Save document metadata changes"""
try:
# Validate inputs
if not title:
self.notification_area.object = "**Error:** Title is required"
return
# Update document
update_result = update_document(
document_uid=self.document_uid,
user=self.user,
data={
'title': title,
'description': description
}
)
if update_result and update_result.get('success'):
self.notification_area.object = "Document updated successfully"
# Reload document
self._load_document()
else:
error_msg = update_result.get('message', 'An error occurred')
self.notification_area.object = f"**Error:** {error_msg}"
except Exception as e:
logger.error(f"Error saving document changes: {e}")
self.notification_area.object = f"**Error:** {str(e)}"
def _show_upload_form(self, event=None):
"""Show form to upload a new document version"""
self.notification_area.object = "Loading upload form..."
try:
# Create file input
file_input = FileInput(accept='.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt')
# Create comment input
comment_input = TextAreaInput(
name="Version Comment",
placeholder="Enter a comment for this version",
width=400,
height=100
)
# Create upload button
upload_btn = Button(
name="Upload Version",
button_type="success",
width=120
)
# Create cancel button
cancel_btn = Button(
name="Cancel",
button_type="default",
width=120
)
# Create form layout
upload_form = Column(
Markdown("# Upload New Version"),
Markdown("Select a file to upload as a new version of this document."),
file_input,
comment_input,
Row(
cancel_btn,
upload_btn,
align='end'
),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
width=450
)
# Set up event handlers
upload_btn.on_click(lambda event: self._upload_new_version(
file_input.value,
file_input.filename,
comment_input.value
))
cancel_btn.on_click(self._load_document)
# Clear display area and show form
# FIX: Check if template exists, otherwise use main_content
if self.template and hasattr(self.template, 'main'):
# Standalone mode using template
self.tabs.clear()
self.template.main.clear()
self.template.main.append(self.notification_area)
self.template.main.append(upload_form)
else:
# Embedded mode using main_content
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(upload_form)
# Clear notification
self.notification_area.object = ""
except Exception as e:
logger.error(f"Error showing upload form: {e}")
self.notification_area.object = f"**Error:** {str(e)}"
def _upload_new_version(self, file_content, file_name, comment):
"""Upload a new document version using FileCloud for storage"""
try:
# Validate file
if not file_content:
self.notification_area.object = "**Error:** Please select a file to upload"
return
# Show upload in progress message
self.notification_area.object = "**Uploading new version...**"
# First create the document version in Neo4j
from CDocs.controllers.document_controller import create_document_version
# Call create_document_version which will handle the Neo4j part
result = create_document_version(
user=self.user,
document_uid=self.document_uid,
file_content=file_content,
file_name=file_name,
comment=comment
)
if not result:
error_msg = "Failed to create new document version"
self.notification_area.object = f"**Error:** {error_msg}"
return
doc=ControlledDocument(uid=self.document_uid)
doc.set_current_version(result.get('UID'))
# Now upload the file to FileCloud
from CDocs.controllers.filecloud_controller import upload_document_to_filecloud
# Prepare metadata for FileCloud
# metadata = {
# "doc_uid": self.document_uid,
# "doc_number": self.doc_number,
# "version_uid": result.get('UID'),
# "version_number": result.get('version_number'),
# "title": self.doc_title,
# "status": self.doc_status,
# "owner": self.doc_owner,
# "comment": comment
# }
# Upload to FileCloud
filecloud_result = upload_document_to_filecloud(
user=self.user,
document=doc,
file_content=file_content,
version_comment=comment,
metadata=None
)
if not filecloud_result or not filecloud_result.get('success'):
error_msg = "Failed to upload file to FileCloud storage"
self.notification_area.object = f"**Error:** {error_msg}"
return
# Success!
self.notification_area.object = "New version uploaded successfully"
# Reload document
self._load_document()
except Exception as e:
logger.error(f"Error uploading new version: {e}")
import traceback
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error uploading new version:** {str(e)}"
# Helper methods
def _format_date(self, date_str):
"""Format date string for display"""
if not date_str:
return None
try:
date = datetime.fromisoformat(date_str)
return date.strftime('%Y-%m-%d')
except Exception:
return date_str
def _get_status_color(self, status_code):
"""Get color for document status"""
return settings.get_status_color(status_code)
# Form placeholders for actions that would be implemented
def _show_review_form(self, event=None):
"""Show form to start a review cycle"""
self.notification_area.object = "Loading review form..."
try:
# Create form elements
from CDocs.models.user_extensions import DocUser
# Get all users who can be reviewers
reviewers = []
try:
# Directly query users since get_potential_reviewers doesn't exist
from CDocs.db.db_operations import run_query # Changed from 'from CDocs.db import db'
reviewers_result = run_query(
"""
MATCH (u:User)
WHERE (u.UID <> $current_user_uid) and not('Template' in labels(u))
RETURN u.UID as uid, u.Name as name, u.Role as role, u.Department as department
ORDER BY u.Name
""",
{"current_user_uid": self.user.uid}
)
reviewers = [{"uid": r["uid"], "name": r["name"], "role": r.get("role", ""),
"department": r.get("department", "")} for r in reviewers_result]
except Exception as e:
# If db query fails, use a simple fallback with an empty list
logger.warning(f"Error fetching reviewers: {e}")
reviewers = []
# Fix for Panel 1.6.1 - create the widget with an options dictionary instead of tuples
reviewer_options = []
for r in reviewers:
# Handle different possible data structures
if isinstance(r, dict):
uid = r.get("uid", "")
name = r.get("name", "Unknown")
role = r.get("role", "")
display_text = f"{name} ({role})" if role else name
reviewer_options.append((uid, display_text))
else:
# Fallback for non-dictionary items
logger.warning(f"Unexpected reviewer data format: {type(r)}")
# Fix for Panel 1.6.1 - invert the dictionary mapping to fix display issue
# Use display text as key and UID as value (this is counter-intuitive but works for Panel 1.6.1)
reviewer_display_dict = {display: uid for uid, display in reviewer_options}
reviewer_select = pn.widgets.MultiSelect(
name="Select Reviewers",
options=reviewer_display_dict, # Use inverted dictionary
value=[],
size=6,
width=400
)
# Create due date picker (default to 2 weeks from now)
# Convert the date string to a proper date object
default_due_date = (datetime.now() + timedelta(days=14)).date() # Use .date() to get date object
due_date_picker = pn.widgets.DatePicker(
name="Due Date",
value=default_due_date,
width=200
)
# Create review type dropdown
review_type_select = pn.widgets.Select(
name="Review Type",
options=settings.REVIEW_TYPES,
value="STANDARD",
width=200
)
# Create sequential checkbox
sequential_check = pn.widgets.Checkbox(
name="Sequential Review",
value=False
)
# Required approval percentage (default 100%)
approval_pct = pn.widgets.IntSlider(
name="Required Approval Percentage",
start=1,
end=100,
value=100,
step=1,
width=200
)
# Instructions textarea
instructions_input = pn.widgets.TextAreaInput(
name="Instructions for Reviewers",
placeholder="Enter instructions for reviewers",
width=400,
height=100
)
# Create start button
start_btn = pn.widgets.Button(
name="Start Review Cycle",
button_type="success",
width=150
)
# Create cancel button
cancel_btn = pn.widgets.Button(
name="Cancel",
button_type="default",
width=120
)
# Create form layout
review_form = pn.Column(
pn.pane.Markdown("# Start Review Cycle"),
pn.pane.Markdown(f"## {self.doc_number}: {self.doc_title}"),
reviewer_select,
pn.Row(
review_type_select,
due_date_picker
),
pn.Row(
sequential_check,
approval_pct
),
instructions_input,
pn.Row(
cancel_btn,
start_btn,
align='end'
),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
width=500
)
# Set up event handlers
start_btn.on_click(lambda event: self._start_review_cycle(
reviewer_uids=reviewer_select.value,
due_date=due_date_picker.value,
review_type=review_type_select.value,
sequential=sequential_check.value,
required_approval_percentage=approval_pct.value,
instructions=instructions_input.value
))
cancel_btn.on_click(self._load_document)
# Clear display area and show form
if self.template and hasattr(self.template, 'main'):
# Standalone mode using template
self.tabs.clear()
self.template.main.clear()
self.template.main.append(self.notification_area)
self.template.main.append(review_form)
else:
# Embedded mode using main_content
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(review_form)
# Clear notification
self.notification_area.object = ""
except Exception as e:
logger.error(f"Error showing review form: {e}")
import traceback
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error showing review form:** {str(e)}"
def _start_review_cycle(self, reviewer_uids, due_date, review_type, sequential,
required_approval_percentage, instructions):
"""Start a new review cycle for the current document"""
# Validate inputs
if not reviewer_uids or len(reviewer_uids) == 0:
self.notification_area.object = "**Error:** At least one reviewer must be selected"
return
# Convert due_date string to datetime if needed
if isinstance(due_date, str):
try:
due_date = datetime.fromisoformat(due_date)
except ValueError:
self.notification_area.object = "**Error:** Invalid date format"
return
elif isinstance(due_date, date):
# Convert date to datetime at end of day
due_date = datetime.combine(due_date, datetime.max.time())
# Show processing message
self.notification_area.object = "**Starting review cycle...**"
try:
# Call controller to create review cycle
result = create_review_cycle(
user=self.user,
document_uid=self.document_uid,
reviewer_uids=reviewer_uids,
due_date=due_date,
instructions=instructions,
review_type=review_type,
sequential=sequential,
required_approval_percentage=required_approval_percentage
)
if result['success']:
self.notification_area.object = "**Success:** Review cycle started successfully"
# # Fix: Use properly formatted JavaScript execution
# # In Panel 1.6.1, we need to use a lambda function for execution, not a string directly
# pn.state.execute_with_timeout = lambda delay, callback: pn.state.execute(
# lambda: pn.state.add_timeout_callback(callback, delay)
# )
# # Use the timeout callback system instead of direct JS execution
# pn.state.add_timeout_callback(self._load_document, 1000)
# # Add notification about reloading
# self.notification_area.object += "<br>*Reloading document in 1 second...*"
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Unknown error')}"
except ResourceNotFoundError as e:
self.notification_area.object = f"**Error:** {str(e)}"
except ValidationError as e:
self.notification_area.object = f"**Validation Error:** {str(e)}"
except PermissionError as e:
self.notification_area.object = f"**Permission Error:** {str(e)}"
except BusinessRuleError as e:
self.notification_area.object = f"**Business Rule Error:** {str(e)}"
except Exception as e:
logger.error(f"Error starting review cycle: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** An unexpected error occurred"
def _show_approval_form(self, event=None):
"""Show the form to start an approval workflow"""
if not self.document or not self.document_uid:
self.notification_area.object = "❌ No document selected"
return
# Create form elements
self.notification_area.object = "Setting up approval workflow..."
# Get user options at the beginning so it's available in the entire scope
user_options = self._get_user_options()
# Create workflow type selector
workflow_types = list(settings.APPROVAL_WORKFLOW_TYPES) if hasattr(settings, 'APPROVAL_WORKFLOW_TYPES') else ['STANDARD', 'DEPARTMENT', 'QUALITY']
workflow_type = pn.widgets.Select(
name='Approval Workflow Type',
options=workflow_types,
value='STANDARD'
)
# Create due date picker with default (14 days from now)
default_due = datetime.now() + timedelta(days=14)
due_date = pn.widgets.DatePicker(
name='Due Date',
value=default_due
)
# Create instructions input
instructions = pn.widgets.TextAreaInput(
name='Instructions for Approvers',
placeholder='Enter instructions for approvers...',
rows=3
)
# Create approvers selection
# Here we'll use a dynamic approach to build steps
step_cards = []
# First step (always required)
approver_step1 = self._create_approver_step("Step 1 (Required)",user_options)
step_cards.append(approver_step1)
# Button to add more steps
add_step_btn = pn.widgets.Button(name="+ Add Step", button_type="default", width=150)
# Container for steps
steps_container = pn.Column(
approver_step1,
add_step_btn,
sizing_mode='stretch_width'
)
# Current step count for dynamic addition
step_count = [1] # Using list for mutable reference
def add_step(event):
step_count[0] += 1
new_step = self._create_approver_step(f"Step {step_count[0]}",user_options)
step_cards.append(new_step)
# Insert before the add button
steps_container.insert(-1, new_step)
add_step_btn.on_click(add_step)
# Create submit button
submit_btn = pn.widgets.Button(name='Start Approval', button_type='primary', width=200)
# Create the form
approval_form = pn.Column(
pn.pane.Markdown("# Start Approval Workflow"),
pn.pane.Markdown(f"Document: **{self.doc_number}** - {self.doc_title}"),
workflow_type,
due_date,
instructions,
pn.pane.Markdown("## Approval Steps"),
pn.pane.Markdown("Define who needs to approve the document and in what order:"),
steps_container,
pn.layout.Spacer(height=20),
submit_btn,
sizing_mode='stretch_width'
)
# Handle submission
def submit_approval(event):
try:
self.notification_area.object = "⏳ Starting approval workflow..."
# Collect approvers from each step
steps_data = []
for i, step_card in enumerate(step_cards, 1):
# Get the widgets from the object attributes instead of param
if not hasattr(step_card, 'approvers_select') or not hasattr(step_card, 'all_approve_check'):
self.notification_area.object = f"❌ Error: Step {i} widget references not found"
return
approvers_select = step_card.approvers_select
all_approve_check = step_card.all_approve_check
selected_uids = approvers_select.value
# Debug the selected values
print(f"Step {i} selected values: {selected_uids}")
if not selected_uids or len(selected_uids) == 0:
self.notification_area.object = f"❌ Please select at least one approver for Step {i}"
return
# Create step data with 'approvers' key instead of 'approver_uids'
steps_data.append({
'approvers': [{'user_uid': uid} for uid in selected_uids], # Each approver is a dict with user_uid
'all_must_approve': all_approve_check.value,
'step_number': i,
'required_approvals': 1 if all_approve_check.value else len(selected_uids)
})
# Validate we have at least one approver
if not steps_data:
self.notification_area.object = "❌ Please add at least one approver"
return
# Call API to create approval workflow
from CDocs.controllers.approval_controller import create_approval_workflow
result = create_approval_workflow(
user=self.user,
document_uid=self.document_uid,
steps=steps_data,
workflow_type=workflow_type.value,
due_date=due_date.value,
instructions=instructions.value
)
if result.get('success'):
self.notification_area.object = "✅ Approval workflow started successfully"
# Reload document to show updated status
self._load_document()
else:
self.notification_area.object = f"❌ Error: {result.get('message', 'Unknown error')}"
except Exception as e:
self.notification_area.object = f"❌ Error: {str(e)}"
logger.error(f"Error starting approval: {e}")
logger.error(traceback.format_exc())
submit_btn.on_click(submit_approval)
# Show the form in the main content area
self.main_content.clear()
self.main_content.append(approval_form)
def _create_approver_step(self, title,user_options):
"""Helper to create a step card for approval workflow"""
# Create a multi-select for approvers
approvers_select = pn.widgets.MultiSelect(
name='Approvers',
options=user_options,
size=5
)
# Step options
all_approve_check = pn.widgets.Checkbox(
name='All must approve',
value=True
)
options_row = pn.Row(
all_approve_check,
name='Step Options'
)
# Create step card
step_card = pn.Column(
pn.pane.Markdown(f"### {title}"),
approvers_select,
options_row,
styles={'background': '#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'mb-3'],
sizing_mode='stretch_width'
)
# Store references directly as attributes on the object instead of using param
step_card.approvers_select = approvers_select
step_card.all_approve_check = all_approve_check
return step_card
def _get_user_options(self):
"""Get user options for approver selection"""
from CDocs.models.user_extensions import DocUser
try:
potential_approvers = DocUser.get_users_by_role(role="APPROVER")
user_options = {f"{u.name} ({u.username})" : u.uid for u in potential_approvers}
return user_options
except Exception as e:
logger.error(f"Error getting users: {e}")
return {} # Return empty dict on error
def _show_publish_form(self, event=None):
"""Show form to publish the document"""
self.notification_area.object = "Publish form would be shown here"
def _show_archive_form(self, event=None):
"""Show form to archive the document"""
self.notification_area.object = "Archive form would be shown here"
def _show_clone_form(self, event=None):
"""Show form to clone the document"""
try:
# Check if document has a current version
if not self.current_version:
self.notification_area.object = "**Error:** No document version available to clone"
return
# Show loading message
self.notification_area.object = "**Loading clone form...**"
# Create form elements
title_input = pn.widgets.TextInput(
name="Title",
value=f"Copy of {self.doc_title}",
placeholder="Enter a title for the cloned document",
width=400
)
comment_input = pn.widgets.TextAreaInput(
name="Version Comment",
placeholder="Enter a comment for the new version",
value="Clone of document",
width=400,
height=100
)
# Create radio button for clone type
clone_type = pn.widgets.RadioButtonGroup(
name="Clone Type",
options=["New Minor Version", "New Document"],
value="New Minor Version",
button_type="default"
)
# Create department dropdown for new document option
# Only show when "New Document" is selected
from CDocs.config import settings
departments = list(settings.DEPARTMENTS.keys())
department_select = pn.widgets.Select(
name="Department",
options=departments,
value=None,
width=200
)
# Document type dropdown for new document option
doc_types = list(settings.DOCUMENT_TYPES.keys())
doc_type_select = pn.widgets.Select(
name="Document Type",
options=doc_types,
value=None,
width=200
)
# Dynamic display of department and doc type based on clone type
def update_form_fields(event):
if event.new == "New Document":
new_doc_fields.visible = True
else:
new_doc_fields.visible = False
clone_type.param.watch(update_form_fields, 'value')
# Group the new document fields for easy visibility toggle
new_doc_fields = pn.Column(
pn.pane.Markdown("### New Document Details"),
pn.Row(doc_type_select, department_select),
visible=False # Hidden by default
)
# Create submit button
submit_btn = pn.widgets.Button(
name="Clone Document",
button_type="primary",
width=150
)
# Create cancel button
cancel_btn = pn.widgets.Button(
name="Cancel",
button_type="default",
width=100
)
# Create form layout
clone_form = pn.Column(
pn.pane.Markdown("# Clone Document"),
pn.pane.Markdown(f"**Source:** {self.doc_number}: {self.doc_title}"),
pn.pane.Markdown(f"**Version:** {self.current_version.get('version_number', 'Unknown')}"),
pn.Row(clone_type, sizing_mode='stretch_width'),
pn.Row(title_input, sizing_mode='stretch_width'),
new_doc_fields, # New document fields section
pn.Row(comment_input, sizing_mode='stretch_width'),
pn.Row(
pn.layout.HSpacer(),
cancel_btn,
submit_btn,
sizing_mode='stretch_width'
),
width=450,
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'shadow']
)
# Set up event handlers
def submit_clone(event):
# Show processing message
self.notification_area.object = "**Cloning document...**"
try:
# Download the current version
from CDocs.controllers.document_controller import download_document_version
# Get document content
version_uid = self.current_version.get('UID')
doc_content_result = download_document_version(
user=self.user,
document_uid=self.document_uid,
version_uid=version_uid
)
if not isinstance(doc_content_result, dict) or 'content' not in doc_content_result:
self.notification_area.object = "**Error:** Could not download source document"
return
file_content = doc_content_result['content']
file_name = doc_content_result.get('file_name', self.current_version.get('file_name', 'document.pdf'))
if clone_type.value == "New Minor Version":
# Create a new version of the same document
from CDocs.controllers.document_controller import create_document_version
result = create_document_version(
user=self.user,
document_uid=self.document_uid,
file_content=file_content,
file_name=file_name,
comment=comment_input.value
)
if result and 'UID' in result:
# Set as current version
from CDocs.controllers.document_controller import set_current_version
set_result = set_current_version(
user=self.user,
document_uid=self.document_uid,
version_uid=result['UID']
)
# Get document and version objects for FileCloud upload
from CDocs.models.document import ControlledDocument, DocumentVersion
doc = ControlledDocument(uid=self.document_uid)
version = DocumentVersion(uid=result['UID'])
# Upload to FileCloud
from CDocs.controllers.filecloud_controller import upload_document_to_filecloud
filecloud_result = upload_document_to_filecloud(
user=self.user,
document=doc,
file_content=file_content,
version_comment=comment_input.value,
metadata=None
)
if not filecloud_result or not filecloud_result.get('success', False):
self.notification_area.object = f"**Warning:** Document created but FileCloud upload failed: {filecloud_result.get('message', 'Unknown error')}"
else:
# Show success message
self.notification_area.object = "**Success:** Document cloned as new version successfully"
# Reload document after a short delay
self._load_document()
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Failed to create new version')}"
else:
# Create entirely new document based on the current one
from CDocs.controllers.document_controller import clone_document
# Convert selected document type and department names to codes
from CDocs.config import settings
doc_type_code = settings.get_document_type_code(doc_type_select.value)
department_code = settings.get_department_code(department_select.value)
result = clone_document(
user=self.user,
document_uid=self.document_uid,
new_title=title_input.value,
doc_type=doc_type_code,
department=department_code,
include_content=True,
clone_as_new_revision=False
)
if result.get('success', False):
new_doc_uid = result.get('document', {}).get('uid')
new_doc_number = result.get('document', {}).get('doc_number', '')
from CDocs.controllers.document_controller import set_current_version
set_result = set_current_version(
user=self.user,
document_uid=result['document']['uid'],
version_uid=result['document']['version_uid']
)
# Get document and version objects for FileCloud upload
from CDocs.models.document import ControlledDocument
doc = ControlledDocument(uid=new_doc_uid)
# Get the version UID from result or fetch the current version
version_uid = result.get('version', {}).get('uid')
if not version_uid:
# Try to get the current version
current_version = doc.current_version
if current_version:
version_uid = current_version.uid
# Upload to FileCloud
from CDocs.controllers.filecloud_controller import upload_document_to_filecloud
filecloud_result = upload_document_to_filecloud(
user=self.user,
document=doc,
file_content=file_content,
version_comment=comment_input.value,
metadata=None
)
if not filecloud_result or not filecloud_result.get('success', False):
self.notification_area.object = f"""
**Warning:** Document created but FileCloud upload failed
New document: [{new_doc_number}](/document/{new_doc_uid})
Error: {filecloud_result.get('message', 'Unknown error')}
"""
else:
# Show success message with link to new document
self.notification_area.object = f"""
**Success:** Document cloned successfully as new document
New document: [{new_doc_number}](/document/{new_doc_uid})
"""
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Failed to clone document')}"
except Exception as e:
logger.error(f"Error cloning document: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error cloning document:** {str(e)}"
def cancel_action(event):
self.notification_area.object = "Clone operation canceled"
# Remove the form from view
if hasattr(self, 'main_content'):
self.main_content.clear()
self.main_content.append(self.notification_area)
self._load_document()
# Add handlers
submit_btn.on_click(submit_clone)
cancel_btn.on_click(cancel_action)
# Clear display area and show form
if self.template and hasattr(self.template, 'main'):
# Standalone mode using template
self.tabs.clear()
self.template.main.clear()
self.template.main.append(self.notification_area)
self.template.main.append(clone_form)
else:
# Embedded mode using main_content
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(clone_form)
# Clear notification
self.notification_area.object = ""
except Exception as e:
logger.error(f"Error showing clone form: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error showing clone form:** {str(e)}"
def _set_as_current_version(self, event):
"""Set the selected version as the current version"""
logger = logging.getLogger('CDocs.ui.document_detail')
# Debug the stored version UID
logger.debug(f"_set_as_current_version called, stored version UID: {getattr(self, '_selected_version_uid', 'None')}")
if not hasattr(self, '_selected_version_uid') or not self._selected_version_uid:
self.notification_area.object = "**Error:** No version selected"
return
try:
# Get version UID
version_uid = self._selected_version_uid
logger.debug(f"Setting version {version_uid} as current")
# Show in progress message
self.notification_area.object = "**Setting as current version...**"
# Use controller to set current version
from CDocs.controllers.document_controller import set_current_version
result = set_current_version(
user=self.user,
document_uid=self.document_uid,
version_uid=version_uid
)
if result and result.get('success'):
self.notification_area.object = "**Success:** Version set as current"
# Reload document to reflect the changes
self._load_document()
else:
error_msg = "Unknown error"
if result and 'message' in result:
error_msg = result['message']
self.notification_area.object = f"**Error:** Could not set as current version: {error_msg}"
except Exception as e:
logger.error(f"Error setting current version: {e}")
import traceback
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** {str(e)}"
def _download_selected_version(self, event):
"""Download the selected document version"""
logger = logging.getLogger('CDocs.ui.document_detail')
if not hasattr(self, '_selected_version_uid') or not self._selected_version_uid:
self.notification_area.object = "**Error:** No version selected"
return
try:
# Get version UID
version_uid = self._selected_version_uid
logger.debug(f"Downloading version: {version_uid}")
# Show download in progress message
self.notification_area.object = "**Preparing download...**"
# Import the necessary functions
from CDocs.controllers.document_controller import download_document_version
from panel.widgets import FileDownload
# Create a callback function that will fetch the file when the download button is clicked
def get_file_content():
try:
# Get document content with all required parameters
doc_content = download_document_version(
user=self.user,
document_uid=self.document_uid,
version_uid=version_uid
)
if isinstance(doc_content, dict) and 'content' in doc_content and doc_content['content']:
# Return ONLY the BytesIO object, not a tuple
file_name = doc_content.get('file_name', 'document.pdf')
return io.BytesIO(doc_content['content'])
else:
# Handle error
self.notification_area.object = "**Error:** Could not download document"
return None
except Exception as e:
logger.error(f"Error downloading version: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
self.notification_area.object = f"**Error:** {str(e)}"
return None
# Find the version details to get the filename
file_name = "document.pdf" # Default
logger.info("version details: ", self.document)
for version in self.document.get('versions', []):
if version.get('UID') == version_uid:
file_name = version.get('file_name', file_name)
break
# Create download widget with separate filename parameter
download_widget = FileDownload(
callback=get_file_content,
filename=file_name, # Set filename separately
button_type="success",
label=f"Download {file_name}"
)
# Update the version action area to show the download widget
if hasattr(self, '_version_action_area'):
# Find the button row in the action area
for idx, obj in enumerate(self._version_action_area):
if isinstance(obj, pn.Row) and any(isinstance(child, pn.widgets.Button) for child in obj):
# Replace existing download button with our FileDownload widget
new_row = pn.Row(*[child for child in obj if not (isinstance(child, pn.widgets.Button)
and child.name == "Download Version")])
new_row.append(download_widget)
self._version_action_area[idx] = new_row
break
else:
# If no button row found, add the download widget at the end
self._version_action_area.append(download_widget)
self.notification_area.object = "**Ready for download.** Click the download button to save the file."
else:
# If no action area exists, show download widget in notification area
self.notification_area.object = pn.Column(
"**Ready for download:**",
download_widget
)
except Exception as e:
logger.error(f"Error setting up download: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
self.notification_area.object = f"**Error:** {str(e)}"
Parameters
| Name | Type | Default | Kind |
|---|---|---|---|
bases |
param.Parameterized | - |
Parameter Details
bases: Parameter of type param.Parameterized
Return Value
Returns unspecified type
Class Interface
Methods
__init__(self, parent_app)
Purpose: Internal method: init
Parameters:
parent_app: Parameter
Returns: None
set_user(self, user)
Purpose: Set the current user.
Parameters:
user: Parameter
Returns: None
load_document(self, document_uid, doc_number)
Purpose: Load document by UID or document number.
Parameters:
document_uid: Parameterdoc_number: Parameter
Returns: None
get_document_view(self)
Purpose: Get the document view for embedding in other panels.
Returns: None
_get_current_user(self) -> DocUser
Purpose: Get the current user from session
Returns: Returns DocUser
_setup_header(self)
Purpose: Set up the header with title and actions
Returns: None
_setup_sidebar(self)
Purpose: Set up the sidebar with document actions
Returns: None
_setup_main_area(self)
Purpose: Set up the main area with document content tabs
Returns: None
_load_document(self, event)
Purpose: Load document data and update the UI.
Parameters:
event: Parameter
Returns: None
_extract_document_properties(self)
Purpose: Extract document properties from document data.
Returns: None
load_document_data(self, document_data)
Purpose: Load document directly from document data. Parameters: ----------- document_data : dict The document data to load
Parameters:
document_data: Parameter
Returns: None
_setup_document_info(self)
Purpose: Set up document info panel in sidebar
Returns: None
_setup_document_actions(self)
Purpose: Set up document action buttons in sidebar
Returns: None
_create_document_tabs(self)
Purpose: Create tabs for different document content sections
Returns: None
_create_overview_tab(self)
Purpose: Create the overview tab content
Returns: None
_create_versions_tab(self)
Purpose: Create the versions tab content
Returns: None
_edit_document_online(self, event)
Purpose: Get edit URL from FileCloud and open it
Parameters:
event: Parameter
Returns: None
_convert_to_pdf(self, event)
Purpose: Convert the current document version to PDF
Parameters:
event: Parameter
Returns: None
_create_reviews_tab(self)
Purpose: Create the reviews tab content
Returns: None
_review_selected(self, event)
Purpose: Handle review selection from table with support for both selection and cell click events
Parameters:
event: Parameter
Returns: None
_show_extend_review_deadline_form(self, review_uid)
Purpose: Show form to extend review deadline
Parameters:
review_uid: Parameter
Returns: None
_show_add_reviewer_form(self, review_uid)
Purpose: Show form to add a reviewer to an active review cycle
Parameters:
review_uid: Parameter
Returns: None
_show_cancel_review_form(self, review_uid)
Purpose: Show form to cancel an active review cycle
Parameters:
review_uid: Parameter
Returns: None
_load_review_details_by_uid(self, review_uid)
Purpose: Helper method to refresh review details after an action
Parameters:
review_uid: Parameter
Returns: None
_convert_neo4j_datetimes(self, data)
Purpose: Recursively convert all Neo4j DateTime objects to Python datetime objects or strings. Args: data: Any data structure potentially containing Neo4j DateTime objects Returns: Same data structure with Neo4j DateTime objects converted to Python datetime
Parameters:
data: Parameter
Returns: See docstring for return details
_create_approvals_tab(self)
Purpose: Create the approvals tab content with support for the new approval schema
Returns: None
_create_step_card(self, step, step_number)
Purpose: Create a card for an approval step
Parameters:
step: Parameterstep_number: Parameter
Returns: None
_get_status_color(self, status)
Purpose: Get color for a status value
Parameters:
status: Parameter
Returns: None
_view_approval_details(self, approval_uid)
Purpose: View detailed information about an approval workflow
Parameters:
approval_uid: Parameter
Returns: None
_create_audit_tab(self)
Purpose: Create the audit trail tab content
Returns: None
_create_document_viewer(self)
Purpose: Create a document viewer for the current version
Returns: None
_version_selected(self, event)
Purpose: Handle version selection from table
Parameters:
event: Parameter
Returns: None
_approval_selected(self, event)
Purpose: Handle approval selection from table
Parameters:
event: Parameter
Returns: None
_navigate_back(self, event)
Purpose: Navigate back to dashboard
Parameters:
event: Parameter
Returns: None
_view_document(self, event)
Purpose: View the current document version
Parameters:
event: Parameter
Returns: None
_download_current_version(self, event)
Purpose: Download the current document version
Parameters:
event: Parameter
Returns: None
_show_edit_form(self, event)
Purpose: Show form to edit document metadata
Parameters:
event: Parameter
Returns: None
_save_document_changes(self, title, description)
Purpose: Save document metadata changes
Parameters:
title: Parameterdescription: Parameter
Returns: None
_show_upload_form(self, event)
Purpose: Show form to upload a new document version
Parameters:
event: Parameter
Returns: None
_upload_new_version(self, file_content, file_name, comment)
Purpose: Upload a new document version using FileCloud for storage
Parameters:
file_content: Parameterfile_name: Parametercomment: Parameter
Returns: None
_format_date(self, date_str)
Purpose: Format date string for display
Parameters:
date_str: Parameter
Returns: None
_get_status_color(self, status_code)
Purpose: Get color for document status
Parameters:
status_code: Parameter
Returns: None
_show_review_form(self, event)
Purpose: Show form to start a review cycle
Parameters:
event: Parameter
Returns: None
_start_review_cycle(self, reviewer_uids, due_date, review_type, sequential, required_approval_percentage, instructions)
Purpose: Start a new review cycle for the current document
Parameters:
reviewer_uids: Parameterdue_date: Parameterreview_type: Parametersequential: Parameterrequired_approval_percentage: Parameterinstructions: Parameter
Returns: None
_show_approval_form(self, event)
Purpose: Show the form to start an approval workflow
Parameters:
event: Parameter
Returns: None
_create_approver_step(self, title, user_options)
Purpose: Helper to create a step card for approval workflow
Parameters:
title: Parameteruser_options: Parameter
Returns: None
_get_user_options(self)
Purpose: Get user options for approver selection
Returns: None
_show_publish_form(self, event)
Purpose: Show form to publish the document
Parameters:
event: Parameter
Returns: None
_show_archive_form(self, event)
Purpose: Show form to archive the document
Parameters:
event: Parameter
Returns: None
_show_clone_form(self, event)
Purpose: Show form to clone the document
Parameters:
event: Parameter
Returns: None
_set_as_current_version(self, event)
Purpose: Set the selected version as the current version
Parameters:
event: Parameter
Returns: None
_download_selected_version(self, event)
Purpose: Download the selected document version
Parameters:
event: Parameter
Returns: None
Required Imports
import logging
import base64
from typing import Dict
from typing import List
from typing import Any
Usage Example
# Example usage:
# result = DocumentDetail(bases)
Tags
Similar Components
AI-powered semantic similarity - components with related functionality:
-
class DocumentDetail_v2 98.7% similar
-
class DocumentDetail 98.4% similar
-
function create_document_detail 57.7% similar
-
class DocumentDashboard 55.7% similar
-
class Document 53.5% similar