class DocumentDetail_v3
Document detail view component
/tf/active/vicechatdev/CDocs single class/ui/document_detail.py
69 - 4335
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 details"""
if not self.document_uid:
self.notification_area.object = "**Error:** No document ID provided"
return
try:
self.notification_area.object = "Loading document..."
# Get document from controller
from CDocs.controllers.document_controller import get_document
self.document = get_document(self.document_uid)
if not self.document:
self.notification_area.object = "**Error:** Document not found"
return
# Extract document data
self.doc_title = self.document.get('title', '')
self.doc_number = self.document.get('doc_number', self.document.get('docNumber', ''))
self.doc_status = self.document.get('status', '')
self.doc_owner = self.document.get('owner_name', '')
# Get current version
versions = self.document.get('versions', [])
if versions:
self.current_version = versions[0] # Most recent version
# Extract current version data
self.current_version_number = self.current_version.get('version_number', '')
self.current_version_date = self._format_date(self.current_version.get('created_date', ''))
self.current_version_comment = self.current_version.get('comment', '')
else:
self.current_version = None
logger.info("collected document data versions ", self.current_version)
# Get review/approval data
try:
# Get active review cycle
from CDocs.controllers.review_controller import get_document_review_cycles
reviews = get_document_review_cycles(self.document_uid, include_active_only=True)
if reviews:
self.active_review = reviews['review_cycles'][0] if isinstance(reviews['review_cycles'], list) else None
else:
self.active_review = None
logger.info(f"collected document data reviews {self.active_review}")
# Get active approval cycle
from CDocs.controllers.approval_controller import get_document_approval_cycles
approvals = get_document_approval_cycles(self.document_uid, include_active_only=True)
if approvals:
self.active_approval = approvals['approval_cycles'][0] if isinstance(approvals['approval_cycles'], list) else None
else:
self.active_approval = None
logger.info("collected document data approvals ")
except Exception as e:
logger.error(f"Error loading workflow data: {e}")
self.active_review = None
self.active_approval = None
#logger.info("collected document data", versions,reviews, approvals)
# Update UI
self._update_document_display()
# Clear notification area
self.notification_area.object = ""
return True
except Exception as e:
logger.error(f"Error loading document: {e}")
self.notification_area.object = f"**Error loading document:** {str(e)}"
return False
def _update_document_display(self):
"""Update the document display panels"""
if not self.document:
return
# Create document header
header = self._create_document_header()
#logger.info("collected document data header ", header)
# Create document status display
#status_display = self._create_status_display()
# Create document action buttons
action_buttons = self._create_document_action_buttons()
#logger.info("collected document data action buttons ", action_buttons)
# Create action bar
action_bar = pn.Row(*action_buttons, sizing_mode='stretch_width')
# Create tabs
tabs = pn.Tabs(
('Details', self._create_details_tab()),
('Versions', self._create_versions_tab()),
('Reviews', self._create_reviews_tab()),
('Approvals', self._create_approvals_tab()),
('Audit Trail', self._create_audit_tab())
)
#logger.info("collected document data tabs ", tabs)
# Assemble main content
#logger.info("ready to update display")
self.main_content.clear()
#self.main_content.append(self.notification_area)
#logger.info("ready to update display header")
self.main_content.append(header)
#logger.info("ready to update display action")
self.main_content.append(action_bar)
#logger.info("ready to update display divider")
self.main_content.append(pn.layout.Divider())
#logger.info("ready to update display tabs")
self.main_content.append(tabs)
return
def _create_details_tab(self):
"""Create the details tab for document metadata"""
if not self.document:
return pn.Column(pn.pane.Markdown("No document loaded"))
try:
# Extract key document metadata with fallback options
doc_number = self._get_field_case_insensitive(self.document, ['doc_number', 'docNumber'])
title = self._get_field_case_insensitive(self.document, ['title'])
doc_type = self._get_field_case_insensitive(self.document, ['doc_type', 'docType'])
department = self._get_field_case_insensitive(self.document, ['department'])
status = self._get_field_case_insensitive(self.document, ['status'])
description = self._get_field_case_insensitive(self.document, ['description'])
created_date = self._format_date(self._get_field_case_insensitive(self.document, ['created_date', 'createdDate']))
modified_date = self._format_date(self._get_field_case_insensitive(self.document, ['modified_date', 'modifiedDate']))
effective_date = self._format_date(self._get_field_case_insensitive(self.document, ['effective_date']))
expiry_date = self._format_date(self._get_field_case_insensitive(self.document, ['expiry_date']))
owner_name = self._get_field_case_insensitive(self.document, ['owner_name', 'ownerName'])
# Document properties section
properties_section = pn.Column(
pn.pane.Markdown("### Document Properties"),
pn.pane.Markdown(f"**Document Number:** {doc_number}"),
pn.pane.Markdown(f"**Title:** {title}"),
pn.pane.Markdown(f"**Type:** {settings.get_document_type_name(doc_type)}"),
pn.pane.Markdown(f"**Department:** {settings.get_department_name(department)}"),
pn.pane.Markdown(f"**Status:** {settings.get_document_status_name(status)}"),
pn.pane.Markdown(f"**Owner:** {owner_name}"),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
width=350
)
# Dates section
dates_section = pn.Column(
pn.pane.Markdown("### Document Timeline"),
pn.pane.Markdown(f"**Created:** {created_date}"),
pn.pane.Markdown(f"**Last Modified:** {modified_date}"),
pn.pane.Markdown(f"**Effective Date:** {effective_date or 'Not set'}"),
pn.pane.Markdown(f"**Expiry Date:** {expiry_date or 'Not set'}"),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
width=350
)
# Combine properties and dates in a row
info_row = pn.Row(
properties_section,
pn.Spacer(width=20),
dates_section,
sizing_mode='stretch_width'
)
# Description section
description_section = pn.Column(
pn.pane.Markdown("### Description"),
pn.pane.Markdown(description or "No description available"),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
sizing_mode='stretch_width'
)
# Current version preview if available
preview_section = pn.Column(
pn.pane.Markdown("### Document Preview"),
sizing_mode='stretch_width',
height=500
)
if hasattr(self, 'current_version') and self.current_version:
try:
# Create preview - depending on document type, adjust rendering approach
preview_section.append(
pn.pane.Markdown("Document preview will be shown here")
)
except Exception as e:
preview_section.append(
pn.pane.Markdown(f"**Error loading preview:** {str(e)}")
)
else:
preview_section.append(
pn.pane.Markdown("*No document version available for preview*")
)
# Create layout
details_layout = pn.Column(
info_row,
pn.layout.Divider(),
description_section,
pn.layout.Divider(),
preview_section,
sizing_mode='stretch_width'
)
return details_layout
except Exception as e:
logger.error(f"Error creating details tab: {e}")
logger.error(traceback.format_exc())
return pn.Column(pn.pane.Markdown(f"**Error loading details:** {str(e)}"))
def _get_field_case_insensitive(self, doc_dict, field_names):
"""
Get a field value from a document dictionary using case-insensitive matching
and multiple possible field names.
Parameters
----------
doc_dict : dict
Document dictionary
field_names : list
List of possible field names
Returns
-------
str
Field value if found, empty string otherwise
"""
if not doc_dict or not field_names:
return ''
# Convert dictionary keys to lowercase for case-insensitive comparison
lower_dict = {k.lower(): v for k, v in doc_dict.items()}
# Try each field name in order
for name in field_names:
if name.lower() in lower_dict and lower_dict[name.lower()]:
return lower_dict[name.lower()]
# If no match found, try direct access as fallback
for name in field_names:
if name in doc_dict and doc_dict[name]:
return doc_dict[name]
# Return empty string if no match found
return ''
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=100)
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=100)
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=100)
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=100)
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=100)
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=100)
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=100)
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=100)
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=100)
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=100)
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 _review_selected(self, event=None,review_uid=None):
"""Handle review selection from table with support for both selection and cell click events"""
try:
if review_uid:
self._selected_review_uid=review_uid
else:
# 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 _load_approval_details_by_uid(self, approval_uid):
"""Helper method to refresh approval 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_approval_uid = approval_uid
# Re-trigger the review selection handler
self._approval_selected(dummy_event)
except Exception as e:
logger.error(f"Error refreshing approval details: {e}")
self.notification_area.object = f"**Error refreshing approval details:** {str(e)}"
def _create_document_header(self):
"""
Creates a header section for the document with key metadata.
Returns:
pn.Column: A column containing the document header elements
"""
try:
# Make sure we have document data
if not self.document:
logger.warning("Attempted to create document header with no document data")
return pn.Column(pn.pane.Markdown("# Document Not Available"))
doc = self.document
# Get document metadata with fallbacks for different field names
doc_number = doc.get('doc_number', doc.get('docNumber', 'N/A'))
title = doc.get('title', 'Untitled Document')
doc_type = doc.get('doc_type', doc.get('docType', ''))
status = doc.get('status', 'DRAFT')
department = doc.get('department', 'N/A')
revision = doc.get('revision', doc.get('version', '1.0'))
# Get document owner information
owner_name = "Unknown"
if 'owner' in doc and doc['owner']:
owner_name = doc['owner'].get('name', "Unknown")
elif 'owner_uid' in doc and doc['owner_uid']:
owner_name = doc.get('owner_name', "Unknown")
# Create header section
header = pn.Column(sizing_mode='stretch_width')
# Document title
header.append(pn.pane.Markdown(f"# {title}"))
# Document metadata
metadata_section = pn.Row(
pn.Column(
pn.pane.Markdown(f"**Document Number:** {doc_number}"),
pn.pane.Markdown(f"**Type:** {self._format_document_type(doc_type)}"),
pn.pane.Markdown(f"**Owner:** {owner_name}"),
width=300
),
pn.Column(
pn.pane.Markdown(f"**Status:** {self._format_document_status(status)}"),
pn.pane.Markdown(f"**Department:** {self._format_department(department)}"),
pn.pane.Markdown(f"**Revision:** {revision}"),
width=300
),
sizing_mode='stretch_width'
)
header.append(metadata_section)
# Add status indicator/badge
status_color = settings.get_status_color(status)
status_name = settings.get_document_status_name(status)
#status_badge = pn.pane.HTML(
# f"""<span class="badge" style="background-color: {status_color};
# padding: 8px 16px; font-size: 14px; border-radius: 16px; color: white;
# font-weight: bold;">{status_name}</span>""",
# width=150
#)
status_badge = self._create_status_display(status)
header.append(status_badge)
# Add document description if available
if 'description' in doc and doc['description']:
header.append(pn.pane.Markdown(f"**Description:** {doc['description']}"))
# Add a separator
header.append(pn.pane.HTML("<hr style='margin: 15px 0;'>"))
return header
except Exception as e:
logger.error(f"Error creating document header: {e}")
logger.error(traceback.format_exc())
return pn.Column(pn.pane.Markdown("# Error Loading Document Header"))
def _format_document_type(self, doc_type: str) -> str:
"""Format document type code to display name."""
return settings.get_document_type_name(doc_type)
def _format_document_status(self, status: str) -> str:
"""Format document status code to display name."""
return settings.get_document_status_name(status)
def _format_department(self, department: str) -> str:
"""Format department code to display name."""
return settings.get_department_name(department)
def _create_status_display(self, status):
"""
Create a status display indicator/badge for a document status.
Args:
status: The document status code
Returns:
pn.pane.HTML: An HTML pane containing the status badge
"""
try:
# Get status color and name from settings
status_color = settings.get_status_color(status)
status_name = settings.get_document_status_name(status)
# Create HTML badge with appropriate styling
html = f"""
<span class="badge" style="background-color: {status_color};
padding: 8px 16px; font-size: 14px; border-radius: 16px; color: white;
font-weight: bold;">{status_name}</span>
"""
# Return as HTML pane
return pn.pane.HTML(html, width=150)
except Exception as e:
logger.error(f"Error creating status display: {e}")
return pn.pane.HTML(f"<span class='badge bg-secondary'>Unknown</span>")
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_reviews_tab(self):
"""Create the reviews tab showing all review cycles for this document"""
try:
from CDocs.controllers.review_controller import get_document_review_cycles
# Get all review cycles for this document
reviews = get_document_review_cycles(self.document_uid)
# Convert Neo4j DateTime objects
reviews = self._convert_neo4j_datetimes(reviews)
if not reviews['review_cycles'] or not isinstance(reviews['review_cycles'], list) or len(reviews['review_cycles']) == 0:
return pn.pane.Markdown("No reviews found for this document.")
# Create a dataframe for display
reviews_data = []
for review in reviews['review_cycles']:
# Extract data with fallbacks for different field names
status = review.get('status', '')
review_type = review.get('review_type', '')
start_date = self._format_date(review.get('startDate', review.get('start_date', '')))
due_date = self._format_date(review.get('dueDate', review.get('due_date', '')))
completion_date = self._format_date(review.get('completionDate', review.get('completed_at', '')))
initiated_by = review.get('initiated_by_name', '')
# Get reviewer count with fallbacks
reviewer_count = 0
if 'reviewer_assignments' in review:
reviewer_count = len(review.get('reviewer_assignments', []))
elif 'reviewers' in review:
reviewer_count = len(review.get('reviewers', []))
# Add to data
reviews_data.append({
'UID': review.get('UID'),
'Status': status,
'Type': review_type,
'Started': start_date,
'Due': due_date,
'Completed': completion_date,
'Initiated By': initiated_by,
'Reviewers': reviewer_count
})
# Create DataFrame
df = pd.DataFrame(reviews_data)
# Create selection callback that handles both event types
def selection_callback(event):
# Get the selected review's UID
selected_uid = None
# For TabulatorEvents with .new attribute (Panel versions < 1.0)
if hasattr(event, 'new') and event.new:
selected_uid = event.new[0]['UID']
# For CellClickEvent with .row attribute (newer Panel versions)
elif hasattr(event, 'row') and event.row is not None:
row_index = event.row
if 0 <= row_index < len(df):
selected_uid = df.iloc[row_index]['UID']
# For selection events
elif hasattr(event, 'selected') and event.selected:
rows = event.selected
if rows and len(rows) > 0:
row_index = rows[0]
if 0 <= row_index < len(df):
selected_uid = df.iloc[row_index]['UID']
# Navigate to the review details by loading the review panel
if selected_uid:
self._review_selected(review_uid=selected_uid)
# Create tabulator for display
reviews_table = Tabulator(
df,
selectable=True,
height=400,
layout='fit_columns',
sizing_mode='stretch_width',
header_filters=True,
hidden_columns=['UID'] # Hide UID column
)
# Add selection callback
reviews_table.on_click(selection_callback)
# Create container
reviews_tab = pn.Column(
pn.pane.Markdown("## Document Reviews"),
reviews_table,
sizing_mode='stretch_width'
)
return reviews_tab
except Exception as e:
logger.error(f"Error creating reviews tab: {e}")
return pn.pane.Markdown(f"**Error loading reviews:** {str(e)}")
def _create_approvals_tab(self):
"""Create the approvals tab showing all approval cycles for this document"""
try:
from CDocs.controllers.approval_controller import get_document_approval_cycles
# Get all approval cycles for this document
approvals = get_document_approval_cycles(self.document_uid)
if not approvals or not isinstance(approvals, list) or len(approvals) == 0:
return pn.pane.Markdown("No approvals found for this document.")
# Create a dataframe for display
approvals_data = []
for approval in approvals:
# Extract data with fallbacks for different field names
status = approval.get('status', '')
approval_type = approval.get('approval_type', '')
sequential = "Sequential" if approval.get('sequential', True) else "Parallel"
start_date = self._format_date(approval.get('startDate', approval.get('start_date', '')))
due_date = self._format_date(approval.get('dueDate', approval.get('due_date', '')))
completion_date = self._format_date(approval.get('completionDate', approval.get('completed_at', '')))
initiated_by = approval.get('initiated_by_name', '')
# Get approver count with fallbacks
approver_count = 0
if 'approver_assignments' in approval:
approver_count = len(approval.get('approver_assignments', []))
elif 'approvers' in approval:
approver_count = len(approval.get('approvers', []))
# Add to data
approvals_data.append({
'UID': approval.get('UID'),
'Status': status,
'Type': approval_type,
'Flow': sequential,
'Started': start_date,
'Due': due_date,
'Completed': completion_date,
'Initiated By': initiated_by,
'Approvers': approver_count
})
# Create DataFrame
df = pd.DataFrame(approvals_data)
# Create selection callback that handles both event types
def selection_callback(event):
# Get the selected review's UID
selected_uid = None
# For TabulatorEvents with .new attribute (Panel versions < 1.0)
if hasattr(event, 'new') and event.new:
selected_uid = event.new[0]['UID']
# For CellClickEvent with .row attribute (newer Panel versions)
elif hasattr(event, 'row') and event.row is not None:
row_index = event.row
if 0 <= row_index < len(df):
selected_uid = df.iloc[row_index]['UID']
# For selection events
elif hasattr(event, 'selected') and event.selected:
rows = event.selected
if rows and len(rows) > 0:
row_index = rows[0]
if 0 <= row_index < len(df):
selected_uid = df.iloc[row_index]['UID']
# Navigate to the review page if we found a UID
if selected_uid:
self._load_approval_details_by_uid(selected_uid)
# Create tabulator for display
approvals_table = Tabulator(
df,
selectable=True,
height=400,
layout='fit_columns',
sizing_mode='stretch_width',
header_filters=True,
hidden_columns=['UID'] # Hide UID column
)
# Add selection callback
approvals_table.on_click(selection_callback)
# Create container
approvals_tab = pn.Column(
pn.pane.Markdown("## Document Approvals"),
approvals_table,
sizing_mode='stretch_width'
)
return approvals_tab
except Exception as e:
logger.error(f"Error creating approvals tab: {e}")
return pn.pane.Markdown(f"**Error loading approvals:** {str(e)}")
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)
def _show_review_form(self, event=None):
"""Show form to start a review cycle"""
self.notification_area.object = "Loading review form..."
import time
time.sleep(5)
try:
# Create form elements
from CDocs.models.user_extensions import DocUser
# Get all users who can be reviewers
reviewers = []
try:
# Get users with REVIEW_DOCUMENT permission or REVIEWER role
reviewers = DocUser.get_users_by_role(role="REVIEWER")
if not reviewers:
reviewers = DocUser.get_users_by_permission("REVIEW_DOCUMENT")
except Exception as e:
logger.error(f"Error fetching reviewers: {e}")
reviewers = []
# Create reviewer selection options dictionary
reviewer_options = {f"{r.name} ({r.username})": r.uid for r in reviewers}
reviewer_select = pn.widgets.MultiSelect(
name="Select Reviewers",
options=reviewer_options,
value=[],
size=6,
width=400
)
# Create due date picker (default to 2 weeks from now)
default_due_date = (datetime.now() + timedelta(days=settings.DEFAULT_REVIEW_PERIOD_DAYS)).date()
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=settings.REVIEW_TYPES[0] if settings.REVIEW_TYPES else "STANDARD",
width=200
)
# Create sequential checkbox
sequential_check = pn.widgets.Checkbox(
name="Sequential Review",
value=settings.SEQUENTIAL_REVIEW_DEFAULT,
)
# Required approval percentage (default 100%)
approval_pct = pn.widgets.IntSlider(
name="Required Approval Percentage",
start=1,
end=100,
value=settings.REQUIRED_APPROVAL_PERCENTAGE_DEFAULT,
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
)
logger.info("form created")
# Set up event handlers
start_btn.on_click(lambda event: self._start_review_cycle(
reviewer_uids=list(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
self.main_content.clear()
self.main_content.append(review_form)
# Clear notification
self.notification_area.object = ""
except Exception as e:
logger.error(f"Error showing review form: {e}")
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 due 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:
# Get current document version UID
if not self.current_version or not self.current_version.get('UID'):
self.notification_area.object = "**Error:** No current document version found"
return
version_uid = self.current_version.get('UID')
# Call controller to create review cycle
from CDocs.controllers.review_controller import create_review_cycle
result = create_review_cycle(
user=self.user,
document_version_uid=version_uid,
reviewers=reviewer_uids,
due_date=due_date,
instructions=instructions,
sequential=sequential,
required_approval_percentage=required_approval_percentage,
review_type=review_type
)
if result and isinstance(result, dict):
self.notification_area.object = "**Review cycle started successfully!**"
# Update document status if needed
if self.doc_status == "DRAFT":
from CDocs.controllers.document_controller import update_document
update_result = update_document(
user=self.user,
document_uid=self.document_uid,
status="IN_REVIEW"
)
if not update_result or not update_result.get('success', False):
self.notification_area.object += "\nWarning: Document status could not be updated."
# Reload document to show updated status
pn.state.onload(lambda: self._load_document())
else:
self.notification_area.object = f"**Error starting review cycle:** {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
from CDocs.models.user_extensions import DocUser
try:
# Get users with APPROVE_DOCUMENT permission or APPROVER role
potential_approvers = DocUser.get_users_by_role(role="APPROVER")
if not potential_approvers:
potential_approvers = DocUser.get_users_by_permission("APPROVE_DOCUMENT")
user_options = {f"{u.name} ({u.username})": u.uid for u in potential_approvers}
if not user_options:
self.notification_area.object = "❌ No users with approval permission found"
return
except Exception as e:
logger.error(f"Error getting users: {e}")
self.notification_area.object = f"❌ Error loading approvers: {str(e)}"
return
# 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=settings.DEFAULT_APPROVAL_PERIOD_DAYS)).date()
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 sequential checkbox
sequential_check = pn.widgets.Checkbox(
name="Sequential Approval Flow",
value=True # Default is sequential for approvals
)
# First step (always required)
approver_step1 = self._create_approver_step("Step 1 (Required)", user_options)
step_cards = [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 cancel button
cancel_btn = pn.widgets.Button(name='Cancel', button_type='default', width=120)
# 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,
sequential_check,
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),
pn.Row(
cancel_btn,
submit_btn,
align='end'
),
sizing_mode='stretch_width'
)
# Handle submission
def submit_approval(event):
try:
self.notification_area.object = "⏳ Starting approval workflow..."
# Get current version UID
if not self.current_version or not self.current_version.get('UID'):
self.notification_area.object = "**Error:** No current document version found"
return
version_uid = self.current_version.get('UID')
# Collect approvers from each step
steps_data = []
for i, step_card in enumerate(step_cards, 1):
# Get the widgets from the object attributes
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 = list(approvers_select.value)
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
steps_data.append({
'approvers': [{'user_uid': uid} for uid in selected_uids],
'all_must_approve': all_approve_check.value,
'step_number': i,
'required_approvals': len(selected_uids) if all_approve_check.value else 1
})
# Call API to create approval workflow
from CDocs.controllers.approval_controller import create_approval_workflow
# Check if we're using the new multi-step API
import inspect
sig = inspect.signature(create_approval_workflow)
if 'steps' in sig.parameters:
# Using new multi-step API
result = create_approval_workflow(
user=self.user,
document_version_uid=version_uid,
steps=steps_data,
workflow_type=workflow_type.value,
due_date=due_date.value,
instructions=instructions.value,
sequential=sequential_check.value
)
else:
# Using older API - flatten approvers from all steps
all_approvers = []
for step in steps_data:
all_approvers.extend([a['user_uid'] for a in step['approvers']])
result = create_approval_workflow(
user=self.user,
document_version_uid=version_uid,
approvers=all_approvers,
due_date=due_date.value,
instructions=instructions.value,
sequential=sequential_check.value
)
if result and (result.get('success') or 'UID' in result):
self.notification_area.object = "✅ Approval workflow started successfully"
# Update document status if needed
if self.doc_status == "DRAFT" or self.doc_status == "IN_REVIEW":
from CDocs.controllers.document_controller import update_document
update_result = update_document(
user=self.user,
document_uid=self.document_uid,
status="IN_APPROVAL"
)
if not update_result or not update_result.get('success', False):
self.notification_area.object += "\nWarning: Document status could not be updated."
# Reload document to show updated status
pn.state.onload(lambda: 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())
# Set up event handlers
submit_btn.on_click(submit_approval)
cancel_btn.on_click(self._load_document)
# 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,
width=350
)
# 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
step_card.approvers_select = approvers_select
step_card.all_approve_check = all_approve_check
return step_card
def _create_document_action_buttons(self):
"""Create buttons for document actions based on permissions and status"""
buttons = []
# Always show view button if we have a current version
if self.current_version:
view_btn = Button(name="View Document", button_type="primary")
view_btn.on_click(self._view_document)
buttons.append(view_btn)
# Show action buttons based on permissions and document status
if self.user:
# Check permissions
can_edit = permissions.user_has_permission(self.user, "EDIT_DOCUMENT")
can_review = permissions.user_has_permission(self.user, "INITIATE_REVIEW")
can_approve = permissions.user_has_permission(self.user, "INITIATE_APPROVAL")
can_publish = permissions.user_has_permission(self.user, "PUBLISH_DOCUMENT")
can_archive = permissions.user_has_permission(self.user, "ARCHIVE_DOCUMENT")
# Show Upload New Version button if document is not archived/obsolete
if can_edit and self.doc_status not in ["ARCHIVED", "OBSOLETE"]:
upload_btn = Button(name="Upload New Version", button_type="default")
upload_btn.on_click(self._show_upload_form)
buttons.append(upload_btn)
# Show Edit Metadata button
if can_edit:
edit_btn = Button(name="Edit Metadata", button_type="default")
edit_btn.on_click(self._show_edit_form)
buttons.append(edit_btn)
# Show Start Review button if document is in DRAFT
if can_review and self.doc_status == "DRAFT":
review_btn = Button(name="Start Review", button_type="warning")
review_btn.on_click(self._show_review_form)
buttons.append(review_btn)
# Show Start Approval button if document is in DRAFT or IN_REVIEW
if can_approve and self.doc_status in ["DRAFT", "IN_REVIEW"]:
approve_btn = Button(name="Start Approval", button_type="warning")
approve_btn.on_click(self._show_approval_form)
buttons.append(approve_btn)
# Show View Current Review button if in review
if self.doc_status == "IN_REVIEW":
view_review_btn = Button(name="View Review", button_type="light")
view_review_btn.on_click(self._view_current_review)
buttons.append(view_review_btn)
# Show View Current Approval button if in approval
if self.doc_status == "IN_APPROVAL":
view_approval_btn = Button(name="View Approval", button_type="light")
view_approval_btn.on_click(self._view_current_approval)
buttons.append(view_approval_btn)
# Show Publish button if document is approved
if can_publish and self.doc_status == "APPROVED":
publish_btn = Button(name="Publish", button_type="success")
publish_btn.on_click(self._show_publish_form)
buttons.append(publish_btn)
# Show Archive button if document is published
if can_archive and self.doc_status == "PUBLISHED":
archive_btn = Button(name="Archive", button_type="danger")
archive_btn.on_click(self._show_archive_form)
buttons.append(archive_btn)
# Show Clone button if document is not archived/obsolete
if self.doc_status not in ["ARCHIVED", "OBSOLETE"]:
clone_btn = Button(name="Clone Document", button_type="default")
clone_btn.on_click(self._show_clone_form)
buttons.append(clone_btn)
# Show Convert button if document is not archived/obsolete
if self.doc_status not in ["ARCHIVED", "OBSOLETE"]:
convert_btn = Button(name="Convert to PDF", button_type="default")
convert_btn.on_click(self._convert_to_pdf)
buttons.append(convert_btn)
return buttons
def _view_current_review(self, event=None):
"""Navigate to review panel for current document"""
if not self.document_uid:
self.notification_area.object = "**Error:** No document selected"
return
try:
# Get latest review cycle
from CDocs.controllers.review_controller import get_document_review_cycles
reviews = get_document_review_cycles(self.document_uid, include_active_only=True)
if not reviews or not isinstance(reviews, list) or len(reviews) == 0:
self.notification_area.object = "No active review found for this document"
return
# Sort by start date descending and take the most recent
if len(reviews) > 1:
reviews.sort(key=lambda x: x.get('startDate', ''), reverse=True)
review_uid = reviews[0].get('UID')
# Navigate to review panel with specific review UID
url = f"/review/{review_uid}"
pn.state.location.pathname = url
except Exception as e:
logger.error(f"Error navigating to review: {e}")
self.notification_area.object = f"**Error navigating to review:** {str(e)}"
def _view_current_approval(self, event=None):
"""Navigate to approval panel for current document"""
if not self.document_uid:
self.notification_area.object = "**Error:** No document selected"
return
try:
# Get latest approval cycle
from CDocs.controllers.approval_controller import get_document_approval_cycles
approvals = get_document_approval_cycles(self.document_uid, include_active_only=True)
if not approvals or not isinstance(approvals, list) or len(approvals) == 0:
self.notification_area.object = "No active approval found for this document"
return
# Sort by start date descending and take the most recent
if len(approvals) > 1:
approvals.sort(key=lambda x: x.get('startDate', ''), reverse=True)
approval_uid = approvals[0].get('UID')
# Navigate to approval panel with specific approval UID
url = f"/approval/{approval_uid}"
pn.state.location.pathname = url
except Exception as e:
logger.error(f"Error navigating to approval: {e}")
self.notification_area.object = f"**Error navigating to approval:** {str(e)}"
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 details
Parameters:
event: Parameter
Returns: None
_update_document_display(self)
Purpose: Update the document display panels
Returns: None
_create_details_tab(self)
Purpose: Create the details tab for document metadata
Returns: None
_get_field_case_insensitive(self, doc_dict, field_names)
Purpose: Get a field value from a document dictionary using case-insensitive matching and multiple possible field names. Parameters ---------- doc_dict : dict Document dictionary field_names : list List of possible field names Returns ------- str Field value if found, empty string otherwise
Parameters:
doc_dict: Parameterfield_names: Parameter
Returns: See docstring for return details
_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
_review_selected(self, event, review_uid)
Purpose: Handle review selection from table with support for both selection and cell click events
Parameters:
event: Parameterreview_uid: 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
_load_approval_details_by_uid(self, approval_uid)
Purpose: Helper method to refresh approval details after an action
Parameters:
approval_uid: Parameter
Returns: None
_create_document_header(self)
Purpose: Creates a header section for the document with key metadata. Returns: pn.Column: A column containing the document header elements
Returns: See docstring for return details
_format_document_type(self, doc_type) -> str
Purpose: Format document type code to display name.
Parameters:
doc_type: Type: str
Returns: Returns str
_format_document_status(self, status) -> str
Purpose: Format document status code to display name.
Parameters:
status: Type: str
Returns: Returns str
_format_department(self, department) -> str
Purpose: Format department code to display name.
Parameters:
department: Type: str
Returns: Returns str
_create_status_display(self, status)
Purpose: Create a status display indicator/badge for a document status. Args: status: The document status code Returns: pn.pane.HTML: An HTML pane containing the status badge
Parameters:
status: Parameter
Returns: See docstring for return details
_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_reviews_tab(self)
Purpose: Create the reviews tab showing all review cycles for this document
Returns: None
_create_approvals_tab(self)
Purpose: Create the approvals tab showing all approval cycles for this document
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
_create_document_action_buttons(self)
Purpose: Create buttons for document actions based on permissions and status
Returns: None
_view_current_review(self, event)
Purpose: Navigate to review panel for current document
Parameters:
event: Parameter
Returns: None
_view_current_approval(self, event)
Purpose: Navigate to approval panel for current document
Parameters:
event: 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_v1 97.9% similar
-
class DocumentDetail_v2 97.5% similar
-
class DocumentDetail 97.3% similar
-
class DocumentDashboard_v1 58.4% similar
-
function create_document_detail 57.6% similar