class DocumentDetail_v2
Document detail view component
/tf/active/vicechatdev/CDocs/ui/document_detail.py
77 - 5782
moderate
Purpose
Document detail view component
Source Code
class DocumentDetail(param.Parameterized):
"""Document detail view component"""
document_uid = param.String(default='')
doc_number = param.String(default='')
current_tab = param.String(default='overview')
def __init__(self, parent_app=None, **params):
super().__init__(**params)
self.parent_app = parent_app
self.template = None # No template needed when embedded
self.session_manager = SessionManager()
self.user = None
self.document = None
self.current_version = None
self.notification_area = pn.pane.Markdown("")
self.main_content = pn.Column(sizing_mode='stretch_width')
# Document info and actions areas
self.doc_info_area = pn.Column(sizing_mode='stretch_width')
self.doc_actions_area = pn.Column(sizing_mode='stretch_width')
# Create tabs for different sections
self.tabs = pn.layout.Tabs(sizing_mode='stretch_width')
def set_user(self, user):
"""Set the current user."""
self.user = user
return True
def load_document(self, document_uid=None, doc_number=None):
"""Load document by UID or document number."""
if document_uid:
self.document_uid = document_uid
if doc_number:
self.doc_number = doc_number
return self._load_document()
def get_document_view(self):
"""Get the document view for embedding in other panels."""
container = pn.Column(
self.notification_area,
self.main_content,
sizing_mode='stretch_width'
)
return container
def _get_current_user(self) -> DocUser:
"""Get the current user from session"""
user_id = self.session_manager.get_user_id()
if user_id:
return DocUser(uid=user_id)
return None
def _setup_header(self):
"""Set up the header with title and actions"""
# Create back button
back_btn = Button(
name='Back to Dashboard',
button_type='default',
width=150
)
back_btn.on_click(self._navigate_back)
# Create refresh button
refresh_btn = Button(
name='Refresh',
button_type='default',
width=100
)
refresh_btn.on_click(self._load_document)
# Header with buttons
header = Row(
pn.pane.Markdown("# Document Details"),
refresh_btn,
back_btn,
sizing_mode='stretch_width',
align='end'
)
self.template.header.append(header)
def _setup_sidebar(self):
"""Set up the sidebar with document actions"""
# Document info area
self.doc_info_area = Column(
sizing_mode='stretch_width'
)
# Document actions area
self.doc_actions_area = Column(
sizing_mode='stretch_width'
)
# Add to sidebar
self.template.sidebar.append(self.doc_info_area)
self.template.sidebar.append(self.doc_actions_area)
def _setup_main_area(self):
"""Set up the main area with document content tabs"""
# Create notification area
self.template.main.append(self.notification_area)
# Create tabs for different sections
self.tabs = Tabs(
sizing_mode='stretch_width'
)
# Add tabs container to main area
self.template.main.append(self.tabs)
def _load_document(self, event=None):
"""Load document data and update the UI."""
try:
# Guard against recursive calls
if hasattr(self, '_loading_document') and self._loading_document:
logger.warning("Recursive call to _load_document avoided")
return False
self._loading_document = True
try:
# Clear notification
self.notification_area.object = ""
# Clear existing UI elements
self.main_content.clear()
self.doc_info_area.clear()
self.doc_actions_area.clear()
self.tabs.clear()
# Get document details using document_uid directly
if self.document_uid:
# Only fetch document if we don't already have it
#if not self.document: removed - otheriwse status updates are not happening
document_data = get_document(document_uid=self.document_uid)
if not document_data:
self.notification_area.object = "**Error:** Document not found"
return False
# Store the document
self.document = document_data
# Extract properties from document data
self._extract_document_properties()
# Create header
doc_header = pn.Column(
pn.pane.Markdown(f"# {self.doc_title or 'Untitled Document'}"),
pn.pane.Markdown(f"**Document Number:** {self.doc_number or 'No number'} | " +
f"**Revision:** {self.doc_revision or 'None'} | " +
f"**Status:** {self.doc_status or 'Unknown'}"),
sizing_mode='stretch_width'
)
# Add header to main content
self.main_content.append(doc_header)
# Set up document info
self._setup_document_info()
# Set up document actions
self._setup_document_actions()
# Create and add tabs for document content
self._create_document_tabs()
# Add document info and actions to main content (in a row to mimic sidebar)
info_actions = pn.Row(
self.doc_info_area,
pn.layout.HSpacer(width=20), # Spacing
self.doc_actions_area
)
# Create layout
layout = pn.Column(
doc_header,
info_actions,
self.tabs,
sizing_mode='stretch_width'
)
# Update main content
self.main_content.clear()
self.main_content.append(layout)
return True
else:
self.notification_area.object = "**Error:** No document UID provided"
return False
finally:
# Always clear the loading flag
self._loading_document = False
except Exception as e:
self._loading_document = False # Ensure flag is cleared
self.notification_area.object = f"**Error:** {str(e)}"
logger.error(f"Error loading document: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return False
def _extract_document_properties(self):
"""Extract document properties from document data."""
if not self.document:
return
# Case-insensitive property extraction
def get_property(data, possible_keys, default=''):
if not data:
return default
for key in possible_keys:
if hasattr(data, 'get'):
value = data.get(key)
if value:
return value
return default
# Extract document metadata with case-insensitive lookup
from CDocs.models.user_extensions import DocUser
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_uid = get_property(self.document, ['ownerUID', 'owner_uid'])
self.doc_owner = DocUser(uid=self.doc_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 _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.name 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', '')
# Add document access controls with permission indicator
if self.current_version:
from CDocs.ui.components import DocumentAccessControls
access_controls = DocumentAccessControls(
document_uid=self.document_uid,
user_uid=self.user.uid if self.user else None,
show_access_indicator=True
)
self.doc_actions_area.append(access_controls.view())
# Add legacy buttons for backward compatibility
# View/download button always available
view_btn = pn.widgets.Button(name="Download Document", button_type="primary", width=200)
view_btn.on_click(self._view_document)
self.doc_actions_area.append(view_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="warning", width=200)
edit_btn.on_click(self._show_edit_form)
self.doc_actions_area.append(edit_btn)
# Upload new version - available if user has create version permission
if permissions.user_has_permission(self.user, "CREATE_VERSION"):
upload_btn = pn.widgets.Button(name="Upload New Version", button_type="default", width=200)
upload_btn.on_click(self._show_upload_form)
self.doc_actions_area.append(upload_btn)
# Add PDF convert button for editable documents
if is_editable_status(status) and permissions.user_has_permission(self.user, "CONVERT_DOCUMENT"):
convert_btn = pn.widgets.Button(name="Convert to PDF", button_type="default", width=200)
convert_btn.on_click(self._convert_to_pdf)
self.doc_actions_area.append(convert_btn)
# Review button - available for draft documents if user has review initiation permission
if status in ['DRAFT'] and permissions.user_has_permission(self.user, "INITIATE_REVIEW"):
review_btn = Button(name="Start Review", button_type="default", width=200)
review_btn.on_click(self._show_review_form)
self.doc_actions_area.append(review_btn)
# Approval button - available for documents in allowed statuses if user has approval initiation permission
if status in settings.APPROVAL_ALLOWED_STATUSES and permissions.user_has_permission(self.user, "INITIATE_APPROVAL"):
approval_btn = Button(name="Start Approval", button_type="default", width=200)
approval_btn.on_click(self._show_approval_form)
self.doc_actions_area.append(approval_btn)
# Publish button - available for approved documents if user has publish permission
if status in ['APPROVED'] and permissions.user_has_permission(self.user, "PUBLISH_DOCUMENT"):
publish_btn = Button(name="Publish Document", button_type="success", width=200)
publish_btn.on_click(self._show_publish_form)
self.doc_actions_area.append(publish_btn)
# Archive button - available for published documents if user has archive permission
if status in ['PUBLISHED', 'EFFECTIVE'] and permissions.user_has_permission(self.user, "ARCHIVE_DOCUMENT"):
archive_btn = Button(name="Archive Document", button_type="danger", width=200)
archive_btn.on_click(self._show_archive_form)
self.doc_actions_area.append(archive_btn)
# Training management button
can_manage_training = (
permissions.user_has_permission(self.user, "MANAGE_TRAINING") or
permissions.user_has_permission(self.user, "MANAGE_ALL_TRAINING")
)
if can_manage_training:
training_btn = Button(name="Manage Training", button_type="primary", width=200)
training_btn.on_click(self._manage_training)
self.doc_actions_area.append(training_btn)
# Clone button - always available if user has create document permission
if permissions.user_has_permission(self.user, "CREATE_DOCUMENT"):
clone_btn = Button(name="Clone Document", button_type="default", width=200)
clone_btn.on_click(self._show_clone_form)
self.doc_actions_area.append(clone_btn)
def _manage_training(self, event=None):
"""Navigate to training management for this document."""
try:
if not self.document_uid:
self.notification_area.object = "**Error:** No document selected"
return
# Navigate to training management via parent app
if self.parent_app and hasattr(self.parent_app, 'navigate_to_training_management'):
self.parent_app.navigate_to_training_management(self.document_uid)
else:
self.notification_area.object = "**Error:** Training management not available"
logger.error("Parent app does not have training management navigation method")
except Exception as e:
logger.error(f"Error navigating to training management: {e}")
self.notification_area.object = f"**Error:** {str(e)}"
def _create_document_tabs(self):
"""Create tabs for different document content sections"""
# Overview tab
self.overview_tab = self._create_overview_tab()
# Versions tab
self.versions_tab = self._create_versions_tab()
# Reviews tab
self.reviews_tab = self._create_reviews_tab()
# Approvals tab
self.approvals_tab = self._create_approvals_tab()
# Audit trail tab
self.audit_tab = self._create_audit_tab()
# Add tabs to the tabs container
self.tabs.extend([
('Overview', self.overview_tab),
('Versions', self.versions_tab),
('Reviews', self.reviews_tab),
('Approvals', self.approvals_tab),
('Audit Trail', self.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'))
# Add training information
training_section = None
try:
from CDocs.models.training import DocumentTraining
doc_training = DocumentTraining(document_uid=self.document_uid)
if doc_training._data.get('training_required', False):
assigned_users = doc_training.get_assigned_users()
training_info = [
"### Training Information",
f"**Training Required:** Yes",
f"**Validity Period:** {doc_training._data.get('validity_days', 365)} days",
f"**Quiz Required:** {'Yes' if doc_training._data.get('quiz_required', False) else 'No'}",
f"**Assigned Users:** {len(assigned_users)}",
]
if doc_training._data.get('instructions'):
training_info.append(f"**Instructions:** {doc_training._data.get('instructions')}")
training_section = Column(
Markdown("\n".join(training_info)),
styles={'background':'#e7f3ff'},
css_classes=['p-3', 'border', 'rounded']
)
else:
training_section = Column(
Markdown("### Training Information\n**Training Required:** No"),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
except Exception as training_error:
logger.warning(f"Error loading training info: {training_error}")
training_section = Column(
Markdown("### Training Information\n**Training Status:** Error loading training information"),
styles={'background':'#fff3cd'},
css_classes=['p-3', 'border', 'rounded']
)
# 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'
),
training_section if training_section else None,
Markdown("## Document Preview"),
preview_pane,
sizing_mode='stretch_width'
)
return overview_layout
def _compare_versions(self, versions_table, versions_df):
"""
Compare two selected document versions using LLM.
Args:
versions_table: The tabulator widget containing the selections
versions_df: The DataFrame with version data
"""
# Get the selected rows
selected_indices = versions_table.selection
# Ensure we have exactly 2 versions selected
if len(selected_indices) != 2:
self.notification_area.object = "**Error:** Please select exactly 2 versions to compare"
return
# Get the UIDs of the selected versions
selected_rows = versions_df.iloc[selected_indices]
version_uids = selected_rows['UID'].tolist()
version_numbers = selected_rows['Version'].tolist()
# Sort versions by version number to ensure version A is always the older version
version_pairs = list(zip(version_numbers, version_uids))
# Convert version numbers to float for numeric comparison if possible
try:
# Try parsing as numeric versions (e.g., 1.0, 2.0)
sorted_pairs = sorted(version_pairs, key=lambda x: float(x[0]))
except ValueError:
# Fallback to string comparison if not numeric
sorted_pairs = sorted(version_pairs, key=lambda x: x[0])
# Extract sorted version numbers and UIDs
sorted_version_numbers = [pair[0] for pair in sorted_pairs]
sorted_version_uids = [pair[1] for pair in sorted_pairs]
self.notification_area.object = f"**Comparing versions:** {sorted_version_numbers[0]} and {sorted_version_numbers[1]}..."
try:
# Create comparison service
from CDocs.utils.version_comparison import VersionComparisonService
comparison_service = VersionComparisonService()
# Get document contents for both versions - in chronological order
version1_content = self._get_version_content(sorted_version_uids[0]) # Older version
version2_content = self._get_version_content(sorted_version_uids[1]) # Newer version
if not version1_content or not version2_content:
self.notification_area.object = "**Error:** Failed to retrieve document content for comparison"
return
# Run the comparison with ordered versions
comparison_result = comparison_service.compare_documents(
version1_content,
version2_content,
sorted_version_numbers[0], # Older version number as version A
sorted_version_numbers[1], # Newer version number as version B
document_title=self.document.get('title', 'Document')
)
# Display results in a modal dialog
self._show_comparison_results(comparison_result, sorted_version_numbers)
except Exception as e:
logger.error(f"Error comparing versions: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error comparing versions:** {str(e)}"
def _get_version_content(self, version_uid):
"""
Get the content of a specific document version as text.
Args:
version_uid: UID of the version
Returns:
Document content as text or None if retrieval fails
"""
try:
# Get document and version information
from CDocs.models.document import DocumentVersion
from CDocs.controllers.filecloud_controller import download_document_from_filecloud
# Get version details
version = DocumentVersion(uid=version_uid)
if not version:
logger.error(f"Version not found: {version_uid}")
return None
document_uid = version.document_uid
if not document_uid:
logger.error(f"Could not determine document UID for version: {version_uid}")
return None
# Download the document content
file_content = download_document_from_filecloud(
document_uid=document_uid,
version=version.version_number
)
# Check if the download was successful
if not isinstance(file_content, bytes):
logger.error(f"Failed to download file content for version: {version_uid}")
return None
# Get file extension to determine processing method
file_name = version.file_name
file_ext = file_name.split('.')[-1].lower() if '.' in file_name else ''
# Process based on file type
if file_ext in ['pdf']:
# For PDFs, extract text using llmsherpa if available
return self._extract_text_from_pdf(file_content)
elif file_ext in ['doc', 'docx', 'rtf', 'odt']:
# Word documents
return self._extract_text_from_word(file_content, file_ext)
elif file_ext in ['ppt', 'pptx']:
# PowerPoint documents
return self._extract_text_from_powerpoint(file_content, file_ext)
elif file_ext in ['xls', 'xlsx']:
# Excel documents
return self._extract_text_from_excel(file_content, file_ext)
else:
# For other file types or fallback, try direct text conversion
try:
# Try UTF-8 first
return file_content.decode('utf-8')
except UnicodeDecodeError:
# Fallback to Latin-1 which never fails
return file_content.decode('latin-1')
except Exception as e:
logger.error(f"Error getting version content: {e}")
logger.error(traceback.format_exc())
return None
return None
def _extract_text_from_pdf(self, file_content):
"""Extract text from PDF content"""
try:
# Save to a temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as temp_file:
temp_path = temp_file.name
temp_file.write(file_content)
try:
# Try using llmsherpa for PDF extraction if available
from llmsherpa.readers import LayoutPDFReader
# Use the llmsherpa API to extract content
llmsherpa_api_url = "http://llmsherpa:5001/api/parseDocument?renderFormat=all&useNewIndentParser=yes"
pdf_reader = LayoutPDFReader(llmsherpa_api_url)
try:
doc = pdf_reader.read_pdf(temp_path)
text_chunks = []
for chunk in doc.chunks():
# Add text content from paragraphs
if hasattr(chunk, 'to_text'):
clean_text = chunk.to_text().replace("- ", "").replace("\n", " ")
text_chunks.append(clean_text)
# Clean up temp file
os.remove(temp_path)
# Return combined text
return "\n\n".join(text_chunks)
except:
# If llmsherpa fails, fall back to PyPDF2
raise ImportError("LLMSherpa failed, falling back to PyPDF2")
except ImportError:
# Fallback to PyPDF2 if llmsherpa is not available
import PyPDF2
with open(temp_path, 'rb') as pdf_file:
pdf_reader = PyPDF2.PdfReader(pdf_file)
text = []
for page_num in range(len(pdf_reader.pages)):
page = pdf_reader.pages[page_num]
text.append(page.extract_text())
# Clean up temp file
os.remove(temp_path)
# Return combined text
return "\n\n".join(text)
except Exception as e:
logger.error(f"Error extracting text from PDF: {e}")
return "Error extracting PDF text: " + str(e)
def _extract_text_from_word(self, file_content, file_ext):
"""Extract text from Word document content"""
try:
# Save to a temporary file
with tempfile.NamedTemporaryFile(suffix=f'.{file_ext}', delete=False) as temp_file:
temp_path = temp_file.name
temp_file.write(file_content)
try:
# Try using the python-docx library for .docx files
if file_ext == 'docx':
import docx
doc = docx.Document(temp_path)
text = []
# Extract text from paragraphs
for para in doc.paragraphs:
text.append(para.text)
# Extract text from tables
for table in doc.tables:
for row in table.rows:
row_text = []
for cell in row.cells:
row_text.append(cell.text)
text.append(" | ".join(row_text))
# Clean up temp file
os.remove(temp_path)
# Return combined text
return "\n\n".join(text)
else:
# For other Word formats, convert to PDF first, then extract text
pdf_path = self._convert_to_pdf(temp_path)
if pdf_path:
with open(pdf_path, 'rb') as pdf_file:
pdf_content = pdf_file.read()
# Clean up temporary files
os.remove(temp_path)
os.remove(pdf_path)
# Extract text from the PDF
return self._extract_text_from_pdf(pdf_content)
else:
raise Exception(f"Failed to convert {file_ext} file to PDF")
except Exception as doc_err:
logger.error(f"Error with python-docx: {doc_err}")
# Fall back to conversion to PDF
pdf_path = self._convert_to_pdf(temp_path)
if pdf_path:
with open(pdf_path, 'rb') as pdf_file:
pdf_content = pdf_file.read()
# Clean up temporary files
os.remove(temp_path)
os.remove(pdf_path)
# Extract text from the PDF
return self._extract_text_from_pdf(pdf_content)
else:
raise Exception(f"Failed to convert {file_ext} file to PDF")
except Exception as e:
logger.error(f"Error extracting text from Word document: {e}")
return "Error extracting Word document text: " + str(e)
def _extract_text_from_powerpoint(self, file_content, file_ext):
"""Extract text from PowerPoint content"""
try:
# Save to a temporary file
with tempfile.NamedTemporaryFile(suffix=f'.{file_ext}', delete=False) as temp_file:
temp_path = temp_file.name
temp_file.write(file_content)
try:
# Try using the python-pptx library for .pptx files
if file_ext == 'pptx':
import pptx
presentation = pptx.Presentation(temp_path)
text = []
# Process each slide
for i, slide in enumerate(presentation.slides):
slide_text = []
slide_text.append(f"Slide {i+1}")
# Get title if available
if slide.shapes.title and slide.shapes.title.text:
slide_text.append(slide.shapes.title.text)
# Extract text from all shapes
for shape in slide.shapes:
if hasattr(shape, "text") and shape.text:
slide_text.append(shape.text)
text.append("\n".join(slide_text))
# Clean up temp file
os.remove(temp_path)
# Return combined text
return "\n\n".join(text)
else:
# For other PowerPoint formats, convert to PDF first, then extract text
pdf_path = self._convert_to_pdf(temp_path)
if pdf_path:
with open(pdf_path, 'rb') as pdf_file:
pdf_content = pdf_file.read()
# Clean up temporary files
os.remove(temp_path)
os.remove(pdf_path)
# Extract text from the PDF
return self._extract_text_from_pdf(pdf_content)
else:
raise Exception(f"Failed to convert {file_ext} file to PDF")
except Exception as ppt_err:
logger.error(f"Error with python-pptx: {ppt_err}")
# Fall back to conversion to PDF
pdf_path = self._convert_to_pdf(temp_path)
if pdf_path:
with open(pdf_path, 'rb') as pdf_file:
pdf_content = pdf_file.read()
# Clean up temporary files
os.remove(temp_path)
os.remove(pdf_path)
# Extract text from the PDF
return self._extract_text_from_pdf(pdf_content)
else:
raise Exception(f"Failed to convert {file_ext} file to PDF")
except Exception as e:
logger.error(f"Error extracting text from PowerPoint: {e}")
return "Error extracting PowerPoint text: " + str(e)
def _extract_text_from_excel(self, file_content, file_ext):
"""Extract text from Excel content"""
try:
# Save to a temporary file
with tempfile.NamedTemporaryFile(suffix=f'.{file_ext}', delete=False) as temp_file:
temp_path = temp_file.name
temp_file.write(file_content)
try:
# Try using pandas for Excel files
import pandas as pd
excel_file = pd.ExcelFile(temp_path)
text = []
# Process each sheet
for sheet_name in excel_file.sheet_names:
df = pd.read_excel(excel_file, sheet_name=sheet_name)
# Skip empty sheets
if df.empty:
continue
# Add sheet name as header
text.append(f"Sheet: {sheet_name}")
# Convert to markdown table
markdown_table = df.to_markdown(index=False)
text.append(markdown_table)
# Clean up temp file
os.remove(temp_path)
# Return combined text
return "\n\n".join(text)
except Exception as excel_err:
logger.error(f"Error with pandas: {excel_err}")
# Fall back to conversion to PDF
pdf_path = self._convert_to_pdf(temp_path)
if pdf_path:
with open(pdf_path, 'rb') as pdf_file:
pdf_content = pdf_file.read()
# Clean up temporary files
os.remove(temp_path)
os.remove(pdf_path)
# Extract text from the PDF
return self._extract_text_from_pdf(pdf_content)
else:
raise Exception(f"Failed to convert {file_ext} file to PDF")
except Exception as e:
logger.error(f"Error extracting text from Excel: {e}")
return "Error extracting Excel text: " + str(e)
def _convert_to_pdf(self, input_file):
"""Convert a document to PDF using LibreOffice"""
try:
output_pdf = os.path.splitext(input_file)[0] + ".pdf"
# Use LibreOffice for conversion
cmd = [
'libreoffice',
'--headless',
'--norestore',
'--convert-to', 'pdf',
'--outdir', os.path.dirname(input_file),
input_file
]
# Run with timeout
process = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60 # 1 minute timeout
)
# Check if there was an error
if process.returncode != 0:
logger.error(f"LibreOffice error: {process.stderr}")
return None
# Verify the file was created
if os.path.exists(output_pdf):
return output_pdf
else:
# Check for other PDFs that might have been created
potential_pdfs = list(Path(os.path.dirname(input_file)).glob(f"{os.path.basename(os.path.splitext(input_file)[0])}*.pdf"))
if potential_pdfs:
return str(potential_pdfs[0])
return None
except Exception as e:
logger.error(f"Error converting to PDF: {e}")
return None
def _show_comparison_results(self, comparison_result, version_numbers):
"""
Display comparison results in a custom modal dialog using FloatPanel for Panel 1.6.1 compatibility.
Args:
comparison_result: Results from the comparison
version_numbers: Version numbers that were compared
"""
try:
# First check if there's already a floatpanel open and clear it
try:
self.floatpanel_comparison.clear()
self.main_content.pop(-1) # Remove the existing floatpanel from workspace
except:
# Create a new floatpanel if it doesn't exist or couldn't be cleared
config = {"headerControls": {"maximize": "remove", "close": "remove"}}
self.floatpanel_comparison = pn.layout.FloatPanel(
name='Version Comparison',
contained=False,
margin=20,
height=800,
width=1000,
position="center",
config=config
)
# Create the title text
title_text = f"Version Comparison: {version_numbers[0]} vs {version_numbers[1]}"
# Create close button
close_btn = pn.widgets.Button(name="Close", button_type="default")
close_btn.window = "self.main_content"
close_btn.floater = "self.floatpanel_comparison"
close_btn.on_click(self.floater_close)
# Create the content for the comparison dialog
comparison_content = pn.Column(
pn.pane.Markdown(f"# {title_text}"),
pn.pane.Markdown(comparison_result),
#pn.Row(pn.layout.HSpacer(), close_btn, align='end'),
sizing_mode='stretch_width',
scroll=True,
height=700,
css_classes=['p-4']
)
# Add content to the floatpanel
self.floatpanel_comparison.append(close_btn)
self.floatpanel_comparison.append(comparison_content)
# Add the panel to the workspace
self.main_content.append(self.floatpanel_comparison)
# Update notification
self.notification_area.object = f"**Comparison complete** between versions {version_numbers[0]} and {version_numbers[1]}"
except Exception as e:
logger.error(f"Error showing comparison results: {e}")
logger.error(traceback.format_exc())
# Fallback: Display results inline
self.notification_area.object = f"""
**Comparison complete** between versions {version_numbers[0]} and {version_numbers[1]}
Unable to display comparison dialog. Results shown below:
<details>
<summary>Click to view comparison results</summary>
<div style="max-height: 400px; overflow-y: auto; border: 1px solid #ddd; padding: 10px;">
{comparison_result}
</div>
</details>
"""
def floater_close(self, event):
"""
Close a floating panel
Args:
event: The event object containing the window and floater properties
"""
try:
# Get the window and floater from the event object
window = event.obj.window
floater = event.obj.floater
# Remove the floater from the window
eval(window+".pop(-1)")
except Exception as e:
logger.error(f"Error closing float panel: {e}")
logger.error(traceback.format_exc())
def _create_versions_tab(self):
"""Create a tab for document versions, with version selection and comparison functionality"""
# Get all versions
versions = self.document.get('versions', [])
# If no versions, show message
if not versions:
return pn.Column(
pn.pane.Markdown("# Document Versions"),
pn.pane.Markdown("No versions available for this document."),
sizing_mode='stretch_width'
)
# Filter out archived versions except for the current version
current_version_uid = None
if self.current_version:
current_version_uid = self.current_version.get('UID')
filtered_versions = []
for version in versions:
# Include if not archived or if it's the current version
if version.get('status') != 'ARCHIVED' or version.get('UID') == current_version_uid:
filtered_versions.append(version)
# Create DataFrame for tabulator
try:
versions_df = pd.DataFrame(filtered_versions)
# Handle different column name cases
version_col = next((col for col in versions_df.columns if col.lower() in ['version_number', 'versionnumber']), None)
if version_col:
versions_df = versions_df.rename(columns={version_col: 'Version'})
created_col = next((col for col in versions_df.columns if col.lower() in ['created_date', 'createddate']), None)
if created_col:
versions_df = versions_df.rename(columns={created_col: 'Created'})
# Format date
try:
versions_df['Created'] = pd.to_datetime(versions_df['Created']).dt.strftime('%Y-%m-%d')
except:
pass
# Add file name if available
file_name_col = next((col for col in versions_df.columns if col.lower() in ['file_name', 'filename']), None)
if file_name_col:
versions_df = versions_df.rename(columns={file_name_col: 'File Name'})
# Add status column if available
status_col = next((col for col in versions_df.columns if col.lower() == 'status'), None)
if status_col:
versions_df = versions_df.rename(columns={status_col: 'Status'})
# Add 'Current' column
versions_df['Current'] = ''
if current_version_uid:
versions_df.loc[versions_df['UID'] == current_version_uid, 'Current'] = '✓'
# Add selection column
versions_df['Selected'] = False
# Add action column
versions_df['Action'] = 'View'
# Select and reorder columns - keep UID but don't display it
columns_to_use = ['UID', 'Selected', 'Version', 'Created', 'Current']
# Add File Name if available
if 'File Name' in versions_df.columns:
columns_to_use.append('File Name')
# Add Status if available
if 'Status' in versions_df.columns:
columns_to_use.append('Status')
# Add Action column last
columns_to_use.append('Action')
# Use only columns that exist in the DataFrame
columns_to_use = [col for col in columns_to_use if col in versions_df.columns]
# Create versions table with selection column
versions_table = pn.widgets.Tabulator(
versions_df[columns_to_use],
pagination='local',
page_size=10,
sizing_mode='stretch_width',
hidden_columns=['UID'],
selectable='checkbox', # Use checkbox for clearer multi-selection
show_index=False
)
# Add version selection handler
versions_table.on_click(self._version_selected)
# Create compare button (initially disabled)
compare_btn = pn.widgets.Button(
name="Compare Selected Versions",
button_type="primary",
width=200,
disabled=True
)
# Handler to enable/disable compare button based on selection
def update_compare_button(event):
# Get the selected indices - in Panel 1.6.1, this is available directly
# Make sure we handle both older and newer Panel versions
selected_indices = []
if hasattr(versions_table, 'selection') and versions_table.selection is not None:
if isinstance(versions_table.selection, list):
selected_indices = versions_table.selection
else:
# Handle case where selection is a string or other type
try:
selected_indices = [int(idx) for idx in str(versions_table.selection).split(',') if idx.strip()]
except:
selected_indices = []
# Enable button only when exactly 2 versions are selected
compare_btn.disabled = len(selected_indices) != 2
# Use the param.watch to monitor the selection parameter
versions_table.param.watch(update_compare_button, 'selection')
# Add compare button handler
compare_btn.on_click(lambda event: self._compare_versions(versions_table, versions_df))
# Create version action area where we'll show details of selected version
self._version_action_area = pn.Column(
pn.pane.Markdown("## Version Details"),
pn.pane.Markdown("*Select a version to view details*"),
sizing_mode='stretch_width',
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
# Layout
versions_layout = pn.Column(
pn.pane.Markdown("# Document Versions"),
pn.Row(compare_btn, align='end', sizing_mode='stretch_width'),
versions_table,
self._version_action_area,
sizing_mode='stretch_width'
)
return versions_layout
except Exception as df_error:
logger.error(f"Error creating versions DataFrame: {df_error}")
return pn.Column(
pn.pane.Markdown("# Document Versions"),
pn.pane.Markdown(f"Error creating versions table: {str(df_error)}"),
sizing_mode='stretch_width'
)
def _edit_document_online(self, event=None):
"""Get edit URL from FileCloud and open it"""
# Show loading message
self.notification_area.object = "**Getting edit URL...**"
try:
# Import the document controller function
from CDocs.controllers.document_controller import get_document_edit_url
# Call API to get edit URL
result = get_document_edit_url(
user=self.user,
document_uid=self.document_uid
)
if result.get('success'):
edit_url = result.get('edit_url')
# Create a clickable link to open the edit URL
self.notification_area.object = f"""
**Document ready for editing!**
Click this link to edit the document online:
[Open in FileCloud Editor]({edit_url})
"""
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Unable to get edit URL')}"
except Exception as e:
import traceback
logger.error(f"Error getting edit URL: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error getting edit URL:** {str(e)}"
def _convert_to_pdf(self, event=None):
"""Convert the current document version to PDF"""
# Show loading message
self.notification_area.object = "**Converting document to PDF...**"
try:
# Import the document controller function
from CDocs.controllers.document_controller import convert_document_to_pdf
# Call API to convert document - FIX: Pass the user object
result = convert_document_to_pdf(
user=self.user, # Pass the user object
document_uid=self.document_uid
)
if result.get('success'):
# Show success message with PDF path
message = f"""
**Document converted to PDF successfully!**
PDF path: {result.get('pdf_path')}
Reload this page to see the updated document.
"""
# Create a new notification with both message and button
notification_column = pn.Column(
pn.pane.Markdown(message),
pn.widgets.Button(name="Reload Document", button_type="primary", on_click=self._load_document)
)
# Replace the notification area with our column
for i, item in enumerate(self.main_content):
if item is self.notification_area:
self.main_content[i] = notification_column
break
if not any(item is notification_column for item in self.main_content):
# If we couldn't find and replace, just update the message
self.notification_area.object = message
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Failed to convert document')}"
except Exception as e:
import traceback
logger.error(f"Error converting document to PDF: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error converting document:** {str(e)}"
def _create_reviews_tab(self):
"""Create the reviews tab content, hiding reviews for archived versions except current one"""
# Get review cycles from document
review_cycles = []
try:
# Call controller to get review cycles
from CDocs.controllers.review_controller import get_document_review_cycles
review_result = get_document_review_cycles(document_uid=self.document_uid)
review_cycles = review_result.get('review_cycles', [])
logger.debug(f"Loaded {len(review_cycles)} review cycles")
except Exception as e:
logger.error(f"Error loading review cycles: {e}")
return pn.Column(
pn.pane.Markdown("# Document Reviews"),
pn.pane.Markdown(f"**Error loading review data:** {str(e)}"),
sizing_mode='stretch_width'
)
if not review_cycles:
# Create button to start review if appropriate
if self.document.get('status') == 'DRAFT' and permissions.user_has_permission(self.user, "INITIATE_REVIEW"):
start_review_btn = pn.widgets.Button(
name="Start Review",
button_type="primary",
width=150
)
start_review_btn.on_click(self._show_review_form)
return pn.Column(
pn.pane.Markdown("# Document Reviews"),
pn.pane.Markdown("*No review cycles found for this document*"),
start_review_btn,
sizing_mode='stretch_width'
)
else:
return pn.Column(
pn.pane.Markdown("# Document Reviews"),
pn.pane.Markdown("*No review cycles found for this document*"),
sizing_mode='stretch_width'
)
# Get current version UID for filtering
current_version_uid = None
if self.current_version:
current_version_uid = self.current_version.get('UID')
# Filter review cycles to exclude those for archived versions (except current version)
filtered_review_cycles = []
for cycle in review_cycles:
version_uid = cycle.get('version_uid')
# Include if it's for the current version or a non-archived version
if version_uid == current_version_uid or not self._is_archived_version(version_uid):
filtered_review_cycles.append(cycle)
# Convert to DataFrame for tabulator
try:
# Create a clean list of dictionaries for the DataFrame
reviews_data = []
for cycle in filtered_review_cycles:
# Convert Neo4j DateTime objects to Python datetime objects or strings
cycle_dict = {}
for key, value in cycle.items():
# Convert Neo4j DateTime objects to strings
if hasattr(value, '__class__') and value.__class__.__name__ == 'DateTime':
# Convert Neo4j DateTime to string in ISO format
try:
cycle_dict[key] = value.iso_format()[:10] # Just get the YYYY-MM-DD part
except (AttributeError, TypeError):
cycle_dict[key] = str(value)
else:
cycle_dict[key] = value
reviews_data.append(cycle_dict)
# Create DataFrame (safely handle empty data)
if not reviews_data:
return pn.Column(
pn.pane.Markdown("# Document Reviews"),
pn.pane.Markdown("*No reviews found for non-archived versions*"),
sizing_mode='stretch_width'
)
reviews_df = pd.DataFrame(reviews_data)
except Exception as df_error:
logger.error(f"Error creating reviews DataFrame: {df_error}")
logger.error(f"Traceback: {traceback.format_exc()}")
return pn.Column(
pn.pane.Markdown("# Document Reviews"),
pn.pane.Markdown(f"*Error formatting review cycle data: {str(df_error)}*"),
sizing_mode='stretch_width'
)
# Format dates - no need to use pd.to_datetime since we already formatted them
date_columns = ['start_date', 'startDate', 'due_date', 'dueDate', 'completed_date', 'completionDate']
for col in date_columns:
if col in reviews_df.columns:
# Instead of using pd.to_datetime, ensure the column contains strings
reviews_df[col] = reviews_df[col].astype(str)
# Select and rename columns for display
display_columns = ['UID', 'cycle_number', 'status', 'initiated_by_name', 'startDate', 'dueDate', 'completionDate']
column_names = {
'UID': 'UID',
'cycle_number': 'Cycle #',
'status': 'Status',
'initiated_by_name': 'Initiated By',
'startDate': 'Started',
'dueDate': 'Due',
'completionDate': 'Completed'
}
# Filter columns that exist in the DataFrame
exist_columns = [col for col in display_columns if col in reviews_df.columns]
reviews_df = reviews_df[exist_columns]
# Rename columns
rename_dict = {col: column_names[col] for col in exist_columns if col in column_names}
reviews_df = reviews_df.rename(columns=rename_dict)
# Add action column
reviews_df['Action'] = 'View'
# Create reviews table
reviews_table = Tabulator(
reviews_df,
pagination='local',
page_size=5,
sizing_mode='stretch_width',
selectable=1,
height=300,
hidden_columns=['UID']
)
# Add review selection handler
reviews_table.on_click(self._review_selected)
# Create review details area
review_details_area = Column(
Markdown("## Review Details"),
Markdown("*Select a review cycle to see details*"),
sizing_mode='stretch_width',
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
# Create start review button if appropriate
buttons = []
if self.document.get('status') == 'DRAFT' and permissions.user_has_permission(self.user, "INITIATE_REVIEW"):
start_review_btn = Button(
name="Start New Review Cycle",
button_type="primary",
width=180
)
start_review_btn.on_click(self._show_review_form)
buttons.append(start_review_btn)
# Layout
reviews_layout = Column(
Markdown("# Document Reviews"),
Row(*buttons, sizing_mode='stretch_width', align='end') if buttons else None,
reviews_table,
review_details_area,
sizing_mode='stretch_width'
)
return reviews_layout
def _is_archived_version(self, version_uid):
"""Check if a version is archived"""
if not version_uid:
return False
versions = self.document.get('versions', [])
for version in versions:
if version.get('UID') == version_uid and version.get('status') == 'ARCHIVED':
return True
return False
def _review_selected(self, event):
"""Handle review selection from table with support for both selection and cell click events"""
logger = logging.getLogger('CDocs.ui.document_detail')
try:
# Clear any previous notifications
self.notification_area.object = ""
# Debug the event type
logger.debug(f"Review selection event type: {type(event).__name__}")
# Handle different event types
row_index = None
review_uid = None
row_data = None
# Check if this is a CellClickEvent
if hasattr(event, 'row') and event.row is not None:
# This is a CellClickEvent
row_index = event.row
logger.debug(f"Cell click event on row {row_index}")
# 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
logger.debug(f"Source data keys: {list(source_data.keys())}")
# Try to find review_uid directly in source data
uid_keys = ['review_uid', '_review_uid', 'UID', 'uid']
for key in uid_keys:
if key in source_data and len(source_data[key]) > row_index:
review_uid = source_data[key][row_index]
logger.debug(f"Found review_uid using key '{key}': {review_uid}")
break
# Create row data dictionary
row_data = {col: values[row_index] for col, values in source_data.items() if len(values) > row_index}
# If still no UID found, check through all columns that might contain UID
if not review_uid:
for key in source_data.keys():
if '_uid' in key.lower() and len(source_data[key]) > row_index:
review_uid = source_data[key][row_index]
logger.debug(f"Found review_uid from column '{key}': {review_uid}")
break
# Handle TabSelector selection event
elif hasattr(event, 'new') and event.new is not None:
row_index = event.new[0] if isinstance(event.new, list) else event.new
logger.debug(f"Selection event with index {row_index}")
# Get DataFrame
if hasattr(event, 'obj') and hasattr(event.obj, 'value'):
df = event.obj.value
logger.debug(f"DataFrame columns: {list(df.columns)}")
if row_index < len(df):
row_data = df.iloc[row_index].to_dict()
# Try different common column names for the UID
uid_columns = ['review_uid', '_review_uid', 'UID', 'uid']
for col in uid_columns:
if col in df.columns:
review_uid = df.iloc[row_index][col]
logger.debug(f"Found review_uid in column '{col}': {review_uid}")
break
# If still no UID found, check through all columns that might contain UID
if not review_uid:
for col in df.columns:
if '_uid' in col.lower():
review_uid = df.iloc[row_index][col]
logger.debug(f"Found review_uid from column '{col}': {review_uid}")
break
# Last resort - check if the event itself has a UID attribute or value
if not review_uid:
if hasattr(event, 'uid'):
review_uid = event.uid
elif hasattr(event, 'value') and isinstance(event.value, str) and len(event.value) > 20:
# Possibly a UID string
review_uid = event.value
if not review_uid:
logger.warning("Could not determine review UID from event")
self.notification_area.object = "**Error:** Couldn't determine review ID"
return
logger.info(f"Loading review with UID: {review_uid}")
# Load review details
from CDocs.controllers.review_controller import get_review_cycle
review_data = get_review_cycle(review_uid, include_document=True, include_comments=True)
if not review_data:
self.notification_area.object = "**Error:** Review not found"
return
review_data = self._convert_neo4j_datetimes(review_data)
# Extract data
status = review_data.get('status', '')
review_type = review_data.get('review_type', '')
initiated_date = self._format_date(review_data.get('initiated_date', ''))
due_date = self._format_date(review_data.get('due_date', ''))
initiated_by = review_data.get('initiated_by_name', '')
instructions = review_data.get('instructions', '')
# Get reviewer data
reviewer_assignments = review_data.get('reviewer_assignments', [])
# Convert to DataFrame for tabulator
reviewer_data = []
for r in reviewer_assignments:
reviewer_data.append({
'reviewer': r.get('reviewer_name', ''),
'status': r.get('status', ''),
'decision': r.get('decision', ''),
'assigned_date': self._format_date(r.get('assigned_date', '')),
'decision_date': self._format_date(r.get('decision_date', ''))
})
reviewers_df = pd.DataFrame(reviewer_data)
# Get comments
comments = review_data.get('comments', [])
# Create details panel
details_panel = pn.Column(
pn.pane.Markdown(f"## Review Cycle Details"),
pn.pane.Markdown(f"**Status:** {status}"),
pn.pane.Markdown(f"**Type:** {review_type}"),
pn.pane.Markdown(f"**Started:** {initiated_date}"),
pn.pane.Markdown(f"**Due Date:** {due_date}"),
pn.pane.Markdown(f"**Initiated By:** {initiated_by}"),
sizing_mode='stretch_width',
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
# Add instructions if available
if instructions:
details_panel.append(pn.pane.Markdown("### Instructions"))
details_panel.append(pn.pane.Markdown(instructions))
# Add reviewers table
details_panel.append(pn.pane.Markdown("### Reviewers"))
details_panel.append(pn.widgets.Tabulator(
reviewers_df,
pagination='local',
page_size=5,
sizing_mode='stretch_width',
height=200
))
# Add comments section
details_panel.append(pn.pane.Markdown("### Comments"))
if comments:
comments_html = "<div class='comments-container'>"
for comment in comments:
user_name = comment.get('user_name', 'Unknown')
timestamp = self._format_date(comment.get('timestamp', ''))
text = comment.get('text', '')
section = comment.get('section', '')
comments_html += f"""
<div class='comment p-2 mb-2 border rounded'>
<div><strong>{user_name}</strong> <span class='text-muted'>on {timestamp}</span></div>
{f"<div><strong>Section:</strong> {section}</div>" if section else ""}
<div>{text}</div>
</div>
"""
comments_html += "</div>"
details_panel.append(pn.pane.HTML(comments_html))
else:
details_panel.append(pn.pane.Markdown("*No comments yet*"))
# Add actions section for document owner or review initiator
if status == 'COMPLETED' and self.user:
# Check if user is document owner, review initiator, or has manage permission
is_document_owner = self.doc_owner_uid == self.user.uid
is_review_initiator = review_data.get('initiated_by_uid') == self.user.uid
has_manage_permission = permissions.user_has_permission(self.user, "MANAGE_REVIEWS")
document_status = review_data.get("document", {}).get("status", "")
# Only show the close review section if the document is still IN_REVIEW
if document_status == "IN_REVIEW" and (is_document_owner or is_review_initiator or has_manage_permission):
logger.info("adding close review section")
# Create close review section
close_review_section = pn.Column(
pn.pane.Markdown("## Close Review Cycle"),
pn.pane.Markdown("The review cycle is completed. You can now close it and update the document status."),
sizing_mode='stretch_width'
)
# Create status dropdown
status_select = pn.widgets.Select(
name="Update Document Status To",
options=['DRAFT', 'APPROVED'],
value='DRAFT',
width=200
)
# Create checkbox for updating status
update_status_checkbox = pn.widgets.Checkbox(
name="Update Document Status",
value=True,
width=200
)
# Create close button
close_btn = pn.widgets.Button(
name="Close Review Cycle",
button_type="primary",
width=150
)
# Create form layout
close_form = pn.Column(
pn.Row(
pn.Column(update_status_checkbox, status_select),
pn.layout.HSpacer(),
pn.Column(close_btn, align='end')
),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
sizing_mode='stretch_width'
)
# Add to section
close_review_section.append(close_form)
# Add handler
from functools import partial
close_btn.on_click(partial(self._close_review_cycle,
review_uid=review_uid,
update_status_checkbox=update_status_checkbox,
status_select=status_select))
# Add section to details panel
details_panel.append(close_review_section)
# Check if this is an active review and user has permission to manage reviews
if status in ['PENDING', 'IN_PROGRESS'] and permissions.user_has_permission(self.user, "MANAGE_REVIEWS"):
# Add admin actions section
admin_actions = pn.Column(
pn.pane.Markdown("## Review Management"),
pn.Row(
pn.widgets.Button(name="Add Reviewer", button_type="default", width=120,
on_click=lambda e: self._show_add_reviewer_form(review_uid)),
pn.widgets.Button(name="Extend Deadline", button_type="default", width=120,
on_click=lambda e: self._show_extend_review_deadline_form(review_uid)),
pn.widgets.Button(name="Cancel Review", button_type="danger", width=120,
on_click=lambda e: self._show_cancel_review_form(review_uid)),
sizing_mode='stretch_width'
),
sizing_mode='stretch_width',
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
details_panel.append(admin_actions)
#logger.info(f"ready to update review details with {details_panel}")
# Update the review details area
#logger.info("available tabs: %s", self.tabs)
details_area = self.reviews_tab
logger.info(f"Details area found: {details_area}")
if details_area:
# Find if there's an existing review details area
review_details_found = False
for i, item in enumerate(details_area):
if isinstance(item, pn.Column) and (
(hasattr(item, 'name') and item.name == 'review_details') or
(len(item) >= 1 and isinstance(item[0], pn.pane.Markdown) and "Review Details" in item[0].object)
):
# Replace existing review details area
details_area[i] = details_panel
#details_panel.name = 'review_details'
review_details_found = True
break
# If not found, append it
if not review_details_found:
#details_panel.name = 'review_details'
details_area.append(details_panel)
else:
# Log error when Reviews tab not found
logger.error("Could not find Reviews tab to update review details")
except Exception as e:
self.notification_area.object = f"**Error:** {str(e)}"
logger.error(f"Error in _review_selected: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
def _close_review_cycle(self, event, review_uid, update_status_checkbox, status_select):
"""Close a review cycle and optionally update document status"""
try:
self.notification_area.object = "Closing review cycle..."
# Call controller to close review cycle
from CDocs.controllers.review_controller import close_review_cycle
result = close_review_cycle(
user=self.user,
review_uid=review_uid,
update_document_status=update_status_checkbox.value,
target_status=status_select.value
)
if result['success']:
self.notification_area.object = "**Success:** Review cycle closed successfully"
# If status was updated, show additional message
if update_status_checkbox.value:
self.notification_area.object += f"<br>Document status updated to {status_select.value}"
# Reload the document after a short delay to reflect changes
time.sleep(1)
self._load_document()
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Unknown error')}"
except ResourceNotFoundError as e:
self.notification_area.object = f"**Error:** {str(e)}"
except ValidationError as e:
self.notification_area.object = f"**Validation Error:** {str(e)}"
except PermissionError as e:
self.notification_area.object = f"**Permission Error:** {str(e)}"
except BusinessRuleError as e:
self.notification_area.object = f"**Business Rule Error:** {str(e)}"
except Exception as e:
logger.error(f"Error closing review cycle: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** An unexpected error occurred"
def _close_approval_cycle(self, event, approval_uid, update_status_checkbox, status_select):
"""Close a review cycle and optionally update document status"""
try:
self.notification_area.object = "Closing approval cycle..."
# Call controller to close approval cycle
from CDocs.controllers.approval_controller import close_approval_cycle
result = close_approval_cycle(
user=self.user,
approval_uid=approval_uid,
update_document_status=update_status_checkbox.value,
target_status=status_select.value
)
if result['success']:
self.notification_area.object = "**Success:** Approval cycle closed successfully"
# If status was updated, show additional message
if update_status_checkbox.value:
self.notification_area.object += f"<br>Document status updated to {status_select.value}"
# Reload the document after a short delay to reflect changes
time.sleep(1)
self._load_document()
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Unknown error')}"
except ResourceNotFoundError as e:
self.notification_area.object = f"**Error:** {str(e)}"
except ValidationError as e:
self.notification_area.object = f"**Validation Error:** {str(e)}"
except PermissionError as e:
self.notification_area.object = f"**Permission Error:** {str(e)}"
except BusinessRuleError as e:
self.notification_area.object = f"**Business Rule Error:** {str(e)}"
except Exception as e:
logger.error(f"Error closing approval cycle: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** An unexpected error occurred"
def _show_add_approver_form(self, approval_uid):
"""Show form to add an approver to an active approval cycle"""
try:
# Store the approval UID for later use
self._selected_approval_uid = approval_uid
# Get current approval cycle
from CDocs.controllers.approval_controller import get_approval_cycle
approval_data = get_approval_cycle(approval_uid)
if not approval_data:
self.notification_area.object = "**Error:** Could not retrieve approval cycle details"
return
# Get potential approvers (exclude current approvers)
from CDocs.models.user_extensions import DocUser
try:
potential_approvers = DocUser.get_users_by_role(role="APPROVER")
if not potential_approvers:
# If no users with explicit APPROVER role, get all users who can approve
potential_approvers = DocUser.get_users_with_permission("COMPLETE_APPROVAL")
# Get current approver UIDs to exclude
current_approver_uids = set()
for approver in approval_data.get('approver_assignments', []):
approver_uid = approver.get('approver_uid')
if approver_uid:
current_approver_uids.add(approver_uid)
# Filter out current approvers
potential_approvers = [u for u in potential_approvers if u.uid not in current_approver_uids]
# Create options dictionary for select widget
user_options = {f"{u.name} ({u.username})" : u.uid for u in potential_approvers}
# If no potential approvers, show message
if not user_options:
self.notification_area.object = "**Error:** No additional users available to be assigned as approvers"
return
except Exception as users_err:
# Fallback - get all users
logger.error(f"Error getting potential approvers: {users_err}")
user_options = {"user1": "User 1", "user2": "User 2"} # Placeholder
# Create form elements
approver_select = pn.widgets.Select(
name="Select Approver",
options=user_options,
width=300
)
# For sequential approvals, add sequence selector
sequence_input = None
if approval_data.get('sequential', False):
sequence_input = pn.widgets.IntInput(
name="Approval Sequence Order",
value=len(approval_data.get('approver_assignments', [])) + 1,
start=1,
width=150
)
# Add instructions field for approver
instructions_input = pn.widgets.TextAreaInput(
name="Instructions for Approver",
placeholder="Enter specific instructions for this approver (optional)",
rows=3,
width=300
)
submit_btn = pn.widgets.Button(
name="Add Approver",
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 Approver to Approval Cycle"),
pn.pane.Markdown(f"Approval: {approval_data.get('status', 'Unknown')}"),
pn.Row(approver_select, sizing_mode='stretch_width'),
pn.Row(instructions_input, 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_approver(event):
# Validate inputs
if not approver_select.value:
self.notification_area.object = "**Error:** You must select an approver"
return
# Call controller function
from CDocs.controllers.approval_controller import add_approver_to_active_approval
try:
result = add_approver_to_active_approval(
user=self.user,
approval_uid=approval_uid,
approver_uid=approver_select.value,
sequence_order=sequence_input.value if sequence_input else None,
instructions=instructions_input.value if instructions_input.value else None
)
if result.get('success', False):
self.notification_area.object = "**Success:** Approver added successfully"
# Reload approval details
self._load_document()
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Failed to add approver')}"
except Exception as e:
self.notification_area.object = f"**Error:** {str(e)}"
# Define cancel handler
def cancel_action(event):
self.notification_area.object = "Approver 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_approver)
cancel_btn.on_click(cancel_action)
# Clear display area and show form
# 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 approver form: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** {str(e)}"
def _show_extend_approval_deadline_form(self, approval_uid):
"""Show form to extend approval deadline"""
try:
# Store the approval UID for later use
self._selected_approval_uid = approval_uid
# Get current approval cycle to show current deadline
from CDocs.controllers.approval_controller import get_approval_cycle
approval_data = get_approval_cycle(approval_uid)
if not approval_data:
self.notification_area.object = "**Error:** Could not retrieve approval cycle details"
return
# Extract current deadline for display
current_deadline = None
if 'dueDate' in approval_data:
try:
current_deadline = datetime.fromisoformat(approval_data['dueDate'])
current_deadline_str = self._format_date(approval_data['dueDate'])
except Exception:
current_deadline_str = approval_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 Approval 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 due 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.approval_controller import extend_approval_deadline
try:
result = extend_approval_deadline(
user=self.user,
approval_uid=approval_uid,
new_due_date=new_deadline,
reason=reason_input.value
)
if result.get('success', False):
self.notification_area.object = "**Success:** Approval deadline extended successfully"
# Reload approval details
self._load_document()
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Failed to extend deadline')}"
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
# 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_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 _show_cancel_approval_form(self, approval_uid):
"""Show form to cancel an active approval cycle"""
try:
# Store the approval UID for later use
self._selected_approval_uid = approval_uid
# Get current approval cycle
from CDocs.controllers.approval_controller import get_approval_cycle
approval_data = get_approval_cycle(approval_uid, include_document=True)
if not approval_data:
self.notification_area.object = "**Error:** Could not retrieve approval cycle details"
return
# Create form elements
reason_input = pn.widgets.TextAreaInput(
name="Reason for Cancellation",
placeholder="Enter reason for canceling the approval 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 Approval",
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 = approval_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 Approval Cycle
**Warning:** You are about to cancel the active approval cycle for document:
**{doc_number}**: {doc_title}
This action cannot be undone. All approver assignments and comments will remain,
but the approval 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_approval(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.approval_controller import cancel_approval_cycle
try:
result = cancel_approval_cycle(
user=self.user,
approval_uid=approval_uid,
reason=reason_input.value
)
if result.get('success', False):
self.notification_area.object = "**Success:** Approval 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_approval)
cancel_btn.on_click(cancel_action)
# Clear display area and show form
# 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 approval form: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** {str(e)}"
def _load_review_details_by_uid(self, review_uid):
"""Helper method to refresh review details after an action"""
try:
# Just re-trigger the review selection with the same review UID
# This will fetch fresh data and update the UI
class DummyEvent:
pass
dummy_event = DummyEvent()
dummy_event.new = [0] # Simulate first row selection
# Store review UID so _review_selected can find it
self._selected_review_uid = review_uid
# Re-trigger the review selection handler
self._review_selected(dummy_event)
except Exception as e:
logger.error(f"Error refreshing review details: {e}")
self.notification_area.object = f"**Error refreshing review details:** {str(e)}"
def _convert_neo4j_datetimes(self, data):
"""
Recursively convert all Neo4j DateTime objects to Python datetime objects or strings.
Args:
data: Any data structure potentially containing Neo4j DateTime objects
Returns:
Same data structure with Neo4j DateTime objects converted to Python datetime
"""
if data is None:
return None
# Handle Neo4j DateTime objects
if hasattr(data, '__class__') and data.__class__.__name__ == 'DateTime':
try:
# Try to convert to Python datetime
import datetime
py_datetime = datetime.datetime(
year=data.year,
month=data.month,
day=data.day,
hour=data.hour,
minute=data.minute,
second=data.second,
microsecond=data.nanosecond // 1000
)
return py_datetime
except (AttributeError, ValueError):
# If conversion fails, return as string
return str(data)
# Handle dictionaries
elif isinstance(data, dict):
return {k: self._convert_neo4j_datetimes(v) for k, v in data.items()}
# Handle lists
elif isinstance(data, list):
return [self._convert_neo4j_datetimes(item) for item in data]
# Handle tuples
elif isinstance(data, tuple):
return tuple(self._convert_neo4j_datetimes(item) for item in data)
# Return other types unchanged
return data
def _create_approvals_tab(self):
"""Create the approvals tab content, hiding approvals for archived versions except current one"""
# Get approval cycles from document
approval_cycles = []
try:
# Call controller to get approval cycles
from CDocs.controllers.approval_controller import get_document_approval_cycles
approval_result = get_document_approval_cycles(document_uid=self.document_uid)
approval_cycles = approval_result.get('approval_cycles', [])
logger.debug(f"Loaded {len(approval_cycles)} approval cycles")
except Exception as e:
logger.error(f"Error loading approval cycles: {e}")
return pn.Column(
pn.pane.Markdown("# Document Approvals"),
pn.pane.Markdown(f"**Error loading approval data:** {str(e)}"),
sizing_mode='stretch_width'
)
if not approval_cycles:
# Create button to start approval if appropriate
if self.document.get('status') == 'DRAFT' and permissions.user_has_permission(self.user, "INITIATE_APPROVAL"):
start_approval_btn = pn.widgets.Button(
name="Start Approval",
button_type="primary",
width=150
)
start_approval_btn.on_click(self._show_approval_form)
return pn.Column(
pn.pane.Markdown("# Document Approvals"),
pn.pane.Markdown("*No approval cycles found for this document*"),
start_approval_btn,
sizing_mode='stretch_width'
)
else:
return pn.Column(
pn.pane.Markdown("# Document Approvals"),
pn.pane.Markdown("*No approval cycles found for this document*"),
sizing_mode='stretch_width'
)
# Get current version UID for filtering
current_version_uid = None
if self.current_version:
current_version_uid = self.current_version.get('UID')
# Filter approval cycles to exclude those for archived versions (except current version)
filtered_approval_cycles = []
for cycle in approval_cycles:
version_uid = cycle.get('version_uid')
# Include if it's for the current version or a non-archived version
if version_uid == current_version_uid or not self._is_archived_version(version_uid):
filtered_approval_cycles.append(cycle)
# Convert to DataFrame for tabulator
try:
# Create a clean list of dictionaries for the DataFrame
approvals_data = []
for cycle in filtered_approval_cycles:
# Convert Neo4j DateTime objects to Python datetime objects or strings
cycle_dict = {}
for key, value in cycle.items():
# Convert Neo4j DateTime objects to strings
if hasattr(value, '__class__') and value.__class__.__name__ == 'DateTime':
# Convert Neo4j DateTime to string in ISO format
try:
cycle_dict[key] = value.iso_format()[:10] # Just get the YYYY-MM-DD part
except (AttributeError, TypeError):
cycle_dict[key] = str(value)
else:
cycle_dict[key] = value
approvals_data.append(cycle_dict)
# Create DataFrame (safely handle empty data)
if not approvals_data:
return pn.Column(
pn.pane.Markdown("# Document Approvals"),
pn.pane.Markdown("*No approvals found for non-archived versions*"),
sizing_mode='stretch_width'
)
approvals_df = pd.DataFrame(approvals_data)
except Exception as df_error:
logger.error(f"Error creating approvals DataFrame: {df_error}")
logger.error(f"Traceback: {traceback.format_exc()}")
return pn.Column(
pn.pane.Markdown("# Document Approvals"),
pn.pane.Markdown(f"*Error formatting approval cycle data: {str(df_error)}*"),
sizing_mode='stretch_width'
)
# Format dates - no need to use pd.to_datetime since we already formatted them
date_columns = ['start_date', 'startDate', 'due_date', 'dueDate', 'completed_date', 'completionDate']
for col in date_columns:
if col in approvals_df.columns:
# Instead of using pd.to_datetime, ensure the column contains strings
approvals_df[col] = approvals_df[col].astype(str)
# Select and rename columns for display
display_columns = ['UID', 'cycle_number', 'status', 'initiated_by_name', 'startDate', 'dueDate', 'completionDate']
column_names = {
'UID': 'UID',
'cycle_number': 'Cycle #',
'status': 'Status',
'initiated_by_name': 'Initiated By',
'startDate': 'Started',
'dueDate': 'Due',
'completionDate': 'Completed'
}
# Filter columns that exist in the DataFrame
exist_columns = [col for col in display_columns if col in approvals_df.columns]
approvals_df = approvals_df[exist_columns]
# Rename columns
rename_dict = {col: column_names[col] for col in exist_columns if col in column_names}
approvals_df = approvals_df.rename(columns=rename_dict)
# Add action column
approvals_df['Action'] = 'View'
# Create approvals table
approvals_table = Tabulator(
approvals_df,
pagination='local',
page_size=5,
sizing_mode='stretch_width',
selectable=1,
height=300,
hidden_columns=['UID']
)
# Add approval selection handler
approvals_table.on_click(self._approval_selected)
# Create approval details area
approval_details_area = Column(
Markdown("## Approval Details"),
Markdown("*Select an approval cycle to see details*"),
sizing_mode='stretch_width',
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
# Create start approval button if appropriate
buttons = []
if self.document.get('status') in settings.APPROVAL_ALLOWED_STATUSES and permissions.user_has_permission(self.user, "INITIATE_APPROVAL"):
start_approval_btn = Button(
name="Start New Approval Cycle",
button_type="primary",
width=180
)
start_approval_btn.on_click(self._show_approval_form)
buttons.append(start_approval_btn)
# Layout
approvals_layout = Column(
Markdown("# Document Approvals"),
Row(*buttons, sizing_mode='stretch_width', align='end') if buttons else None,
approvals_table,
approval_details_area,
sizing_mode='stretch_width'
)
return approvals_layout
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 _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 with support for both selection and cell click events"""
logger = logging.getLogger('CDocs.ui.document_detail')
try:
# Clear any previous notifications
self.notification_area.object = ""
# Debug the event type
logger.debug(f"Review selection event type: {type(event).__name__}")
# Handle different event types
row_index = None
approval_uid = None
row_data = None
# Check if this is a CellClickEvent
if hasattr(event, 'row') and event.row is not None:
# This is a CellClickEvent
row_index = event.row
logger.debug(f"Cell click event on row {row_index}")
# 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
logger.debug(f"Source data keys: {list(source_data.keys())}")
# Try to find approval_uid directly in source data
uid_keys = ['approval_uid', '_approval_uid', 'UID', 'uid']
for key in uid_keys:
if key in source_data and len(source_data[key]) > row_index:
approval_uid = source_data[key][row_index]
logger.debug(f"Found approval_uid using key '{key}': {approval_uid}")
break
# Create row data dictionary
row_data = {col: values[row_index] for col, values in source_data.items() if len(values) > row_index}
# If still no UID found, check through all columns that might contain UID
if not approval_uid:
for key in source_data.keys():
if '_uid' in key.lower() and len(source_data[key]) > row_index:
approval_uid = source_data[key][row_index]
logger.debug(f"Found approval_uid from column '{key}': {approval_uid}")
break
# Handle TabSelector selection event
elif hasattr(event, 'new') and event.new is not None:
row_index = event.new[0] if isinstance(event.new, list) else event.new
logger.debug(f"Selection event with index {row_index}")
# Get DataFrame
if hasattr(event, 'obj') and hasattr(event.obj, 'value'):
df = event.obj.value
logger.debug(f"DataFrame columns: {list(df.columns)}")
if row_index < len(df):
row_data = df.iloc[row_index].to_dict()
# Try different common column names for the UID
uid_columns = ['approval_uid', '_approval_uid', 'UID', 'uid']
for col in uid_columns:
if col in df.columns:
approval_uid = df.iloc[row_index][col]
logger.debug(f"Found approval_uid in column '{col}': {approval_uid}")
break
# If still no UID found, check through all columns that might contain UID
if not approval_uid:
for col in df.columns:
if '_uid' in col.lower():
approval_uid = df.iloc[row_index][col]
logger.debug(f"Found approval_uid from column '{col}': {approval_uid}")
break
# Last resort - check if the event itself has a UID attribute or value
if not approval_uid:
if hasattr(event, 'uid'):
approval_uid = event.uid
elif hasattr(event, 'value') and isinstance(event.value, str) and len(event.value) > 20:
# Possibly a UID string
approval_uid = event.value
if not approval_uid:
logger.warning("Could not determine approval UID from event")
self.notification_area.object = "**Error:** Couldn't determine approval ID"
return
logger.info(f"Loading approval with UID: {approval_uid}")
# Load approval details
from CDocs.controllers.approval_controller import get_approval_cycle
approval_data = get_approval_cycle(approval_uid, include_document=True, include_comments=True)
if not approval_data:
self.notification_area.object = "**Error:** Approval not found"
return
approval_data = self._convert_neo4j_datetimes(approval_data)
# Extract data
status = approval_data.get('status', '')
approval_type = approval_data.get('approval_type', '')
initiated_date = self._format_date(approval_data.get('initiated_date', ''))
due_date = self._format_date(approval_data.get('due_date', ''))
initiated_by = approval_data.get('initiated_by_name', '')
instructions = approval_data.get('instructions', '')
# Get approvaler data
approver_assignments = approval_data.get('approver_assignments', [])
# Convert to DataFrame for tabulator
approver_data = []
for r in approver_assignments:
approver_data.append({
'approver': r.get('approver_name', ''),
'status': r.get('status', ''),
'decision': r.get('decision', ''),
'assigned_date': self._format_date(r.get('assigned_date', '')),
'decision_date': self._format_date(r.get('decision_date', ''))
})
approvers_df = pd.DataFrame(approver_data)
# Get comments
comments = approval_data.get('comments', [])
# Create details panel
details_panel = pn.Column(
pn.pane.Markdown(f"## Approval Cycle Details"),
pn.pane.Markdown(f"**Status:** {status}"),
pn.pane.Markdown(f"**Type:** {approval_type}"),
pn.pane.Markdown(f"**Started:** {initiated_date}"),
pn.pane.Markdown(f"**Due Date:** {due_date}"),
pn.pane.Markdown(f"**Initiated By:** {initiated_by}"),
sizing_mode='stretch_width',
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
# Add instructions if available
if instructions:
details_panel.append(pn.pane.Markdown("### Instructions"))
details_panel.append(pn.pane.Markdown(instructions))
# Add approvers table
details_panel.append(pn.pane.Markdown("### Approvers"))
details_panel.append(pn.widgets.Tabulator(
approvers_df,
pagination='local',
page_size=5,
sizing_mode='stretch_width',
height=200
))
# Add comments section
details_panel.append(pn.pane.Markdown("### Comments"))
if comments:
comments_html = "<div class='comments-container'>"
for comment in comments:
user_name = comment.get('user_name', 'Unknown')
timestamp = self._format_date(comment.get('timestamp', ''))
text = comment.get('text', '')
section = comment.get('section', '')
comments_html += f"""
<div class='comment p-2 mb-2 border rounded'>
<div><strong>{user_name}</strong> <span class='text-muted'>on {timestamp}</span></div>
{f"<div><strong>Section:</strong> {section}</div>" if section else ""}
<div>{text}</div>
</div>
"""
comments_html += "</div>"
details_panel.append(pn.pane.HTML(comments_html))
else:
details_panel.append(pn.pane.Markdown("*No comments yet*"))
# Add actions section for document owner or approval initiator
if status == 'COMPLETED' and self.user:
# Check if user is document owner, approval initiator, or has manage permission
is_document_owner = self.doc_owner_uid == self.user.uid
is_approval_initiator = approval_data.get('initiated_by_uid') == self.user.uid
has_manage_permission = permissions.user_has_permission(self.user, "MANAGE_APPROVALS")
document_status = approval_data.get("document", {}).get("status", "")
# Only show the close approval section if the document is still IN_REVIEW
if document_status == "IN_APPROVAL" and (is_document_owner or is_approval_initiator or has_manage_permission):
logger.info("adding close approval section")
# Create close approval section
close_approval_section = pn.Column(
pn.pane.Markdown("## Close Approval Cycle"),
pn.pane.Markdown("The approval cycle is completed. You can now close it and update the document status."),
sizing_mode='stretch_width'
)
# Create status dropdown
status_select = pn.widgets.Select(
name="Update Document Status To",
options=['DRAFT', 'APPROVED'],
value='DRAFT',
width=200
)
# Create checkbox for updating status
update_status_checkbox = pn.widgets.Checkbox(
name="Update Document Status",
value=True,
width=200
)
# Create close button
close_btn = pn.widgets.Button(
name="Close Approval Cycle",
button_type="primary",
width=150
)
# Create form layout
close_form = pn.Column(
pn.Row(
pn.Column(update_status_checkbox, status_select),
pn.layout.HSpacer(),
pn.Column(close_btn, align='end')
),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
sizing_mode='stretch_width'
)
# Add to section
close_approval_section.append(close_form)
# Add handler
from functools import partial
close_btn.on_click(partial(self._close_approval_cycle,
approval_uid=approval_uid,
update_status_checkbox=update_status_checkbox,
status_select=status_select))
# Add section to details panel
details_panel.append(close_approval_section)
# Check if this is an active approval and user has permission to manage approvals
if status in ['PENDING', 'IN_PROGRESS'] and permissions.user_has_permission(self.user, "MANAGE_APPROVALS"):
# Add admin actions section
admin_actions = pn.Column(
pn.pane.Markdown("## Approval Management"),
pn.Row(
pn.widgets.Button(name="Add Approver", button_type="default", width=120,
on_click=lambda e: self._show_add_approver_form(approval_uid)),
pn.widgets.Button(name="Extend Deadline", button_type="default", width=120,
on_click=lambda e: self._show_extend_approval_deadline_form(approval_uid)),
pn.widgets.Button(name="Cancel Review", button_type="danger", width=120,
on_click=lambda e: self._show_cancel_approval_form(approval_uid)),
sizing_mode='stretch_width'
),
sizing_mode='stretch_width',
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded']
)
details_panel.append(admin_actions)
#logger.info(f"ready to update approval details with {details_panel}")
# Update the approval details area
logger.info("available tabs: %s", self.tabs)
details_area = self.approvals_tab
logger.info(f"Details area found: {details_area}")
if details_area:
# Find if there's an existing approval details area
approval_details_found = False
for i, item in enumerate(details_area):
if isinstance(item, pn.Column) and (
(hasattr(item, 'name') and item.name == 'approval_details') or
(len(item) >= 1 and isinstance(item[0], pn.pane.Markdown) and "Approval Details" in item[0].object)
):
# Replace existing approval details area
details_area[i] = details_panel
#details_panel.name = 'approval_details'
approval_details_found = True
break
# If not found, append it
if not approval_details_found:
#details_panel.name = 'approval_details'
details_area.append(details_panel)
else:
# Log error when Reviews tab not found
logger.error("Could not find Approvals tab to update approval details")
except Exception as e:
self.notification_area.object = f"**Error:** {str(e)}"
logger.error(f"Error in _approval_selected: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
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}:
<a href="{download_url}" target="_blank">Open in FileCloud Viewer</a>
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
# Update sharing permissions for document based on new review cycle
from CDocs.controllers.share_controller import manage_document_permissions
doc = ControlledDocument(uid=doc.uid)
permission_result = manage_document_permissions(doc)
# 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_input):
"""
Format date string or object for display, handling multiple input formats.
Args:
date_input: Date input which could be:
- String in various formats
- datetime.datetime object
- Neo4j DateTime object
- None
Returns:
Formatted date string (YYYY-MM-DD) or None if input is invalid
"""
if not date_input:
return None
try:
# Handle Neo4j DateTime objects
if hasattr(date_input, '__class__') and date_input.__class__.__name__ == 'DateTime':
# Convert Neo4j DateTime to Python datetime
try:
date_obj = datetime(
year=date_input.year,
month=date_input.month,
day=date_input.day,
hour=getattr(date_input, 'hour', 0),
minute=getattr(date_input, 'minute', 0),
second=getattr(date_input, 'second', 0)
)
return date_obj.strftime('%Y-%m-%d')
except (AttributeError, ValueError):
# Fall back to string representation if conversion fails
return str(date_input)
# If it's already a datetime object
if isinstance(date_input, datetime):
return date_input.strftime('%Y-%m-%d')
# If it's a date object
if isinstance(date_input, date) and not isinstance(date_input, datetime):
return date_input.strftime('%Y-%m-%d')
# For string inputs, try multiple formats
if isinstance(date_input, str):
# Try ISO format first (most common in the application)
try:
date_obj = datetime.fromisoformat(date_input)
return date_obj.strftime('%Y-%m-%d')
except ValueError:
pass
# Try parsing with pandas (handles many formats)
try:
import pandas as pd
date_obj = pd.to_datetime(date_input)
return date_obj.strftime('%Y-%m-%d')
except (ValueError, ImportError):
pass
# Try some common formats explicitly
formats_to_try = [
'%Y-%m-%d', # 2023-05-20
'%d/%m/%Y', # 20/05/2023
'%m/%d/%Y', # 05/20/2023
'%Y/%m/%d', # 2023/05/20
'%d-%m-%Y', # 20-05-2023
'%m-%d-%Y', # 05-20-2023
'%b %d, %Y', # May 20, 2023
'%d %b %Y', # 20 May 2023
'%Y-%m-%dT%H:%M:%S', # 2023-05-20T14:30:45
'%Y-%m-%dT%H:%M:%SZ' # 2023-05-20T14:30:45Z
]
for date_format in formats_to_try:
try:
date_obj = datetime.strptime(date_input, date_format)
return date_obj.strftime('%Y-%m-%d')
except ValueError:
continue
# If all parsing attempts fail, return the original string
return date_input
# For unexpected types, convert to string
return str(date_input)
except Exception as e:
logger.debug(f"Error formatting date '{date_input}': {e}")
return str(date_input) if date_input else None
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..."
try:
# Create form elements
from CDocs.models.user_extensions import DocUser
# Get all users who can be reviewers
reviewers = []
try:
# Directly query users since get_potential_reviewers doesn't exist
from CDocs.db.db_operations import run_query
reviewers_result = run_query(
"""
MATCH (u:User)
WHERE (u.UID <> $current_user_uid) and not('Template' in labels(u))
RETURN u.UID as uid, u.Name as name, u.Role as role, u.Department as department
ORDER BY u.Name
""",
{"current_user_uid": self.user.uid}
)
reviewers = [{"uid": r["uid"], "name": r["name"], "role": r.get("role", ""),
"department": r.get("department", "")} for r in reviewers_result]
except Exception as e:
# If db query fails, use a simple fallback with an empty list
logger.warning(f"Error fetching reviewers: {e}")
reviewers = []
# Modified: Create a DataFrame with reviewer data including instructions column
reviewer_data = []
for r in reviewers:
# Handle different possible data structures
if isinstance(r, dict):
uid = r.get("uid", "")
name = r.get("name", "Unknown")
role = r.get("role", "")
display_text = f"{name} ({role})" if role else name
reviewer_data.append({
"Reviewer": display_text,
"UID": uid,
"instructions": "" # Add empty instructions field for each reviewer
})
else:
# Fallback for non-dictionary items
logger.warning(f"Unexpected reviewer data format: {type(r)}")
# Create DataFrame for reviewer table
df = pd.DataFrame(reviewer_data)
# Create tabulator with selection and instructions column
reviewer_table = pn.widgets.Tabulator(
df,
selection=[], # Empty list for selection
pagination='local',
page_size=6,
height=250,
width=400,
selectable='checkbox',
formatters={"instructions": {"type": "input"}},
hidden_columns=['UID']
)
# Create sequential checkbox with callback to show/hide order controls
sequential_check = pn.widgets.Checkbox(
name="Sequential Review",
value=False
)
# Container for sequence order controls
sequence_controls = pn.Column(
pn.pane.Markdown("### Set Review Sequence"),
pn.pane.Markdown("Select reviewers and use buttons to arrange them in order:"),
pn.Row(
pn.widgets.Button(name="⬆️ Move Up", width=120, button_type="default"),
pn.widgets.Button(name="⬇️ Move Down", width=120, button_type="default"),
align='center'
),
visible=False,
styles={'background': '#f5f5f5'},
css_classes=['p-2', 'border', 'rounded']
)
# Show/hide sequence controls based on sequential checkbox
def toggle_sequence_controls(event):
sequence_controls.visible = event.new
sequential_check.param.watch(toggle_sequence_controls, 'value')
# Create due date picker (default to 2 weeks from now)
default_due_date = (datetime.now() + timedelta(days=14)).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="STANDARD",
width=200
)
# Required approval percentage (default 100%)
approval_pct = pn.widgets.IntSlider(
name="Required Approval Percentage",
start=1,
end=100,
value=100,
step=1,
width=200
)
# Instructions textarea
instructions_input = pn.widgets.TextAreaInput(
name="Instructions for Reviewers",
placeholder="Enter general instructions for all reviewers",
width=400,
height=100
)
# Create instruction help text
instruction_help = pn.pane.Markdown("""
**Note:** You can add specific instructions for each reviewer by:
1. Clicking on a cell in the Instructions column
2. Typing the specific instructions for that reviewer
3. Pressing Enter when done
""")
# 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
)
# Function to move reviewers up/down in sequence
def move_reviewer(event):
selected = reviewer_table.selection
if not selected or len(selected) == 0: # Additional check for empty list
return
df = reviewer_table.value
row_idx = selected[0]
if event.obj.name == "⬆️ Move Up" and row_idx > 0:
# Swap with row above
df.iloc[row_idx-1:row_idx+1] = df.iloc[row_idx-1:row_idx+1].iloc[::-1].reset_index(drop=True)
elif event.obj.name == "⬇️ Move Down" and row_idx < len(df)-1:
# Swap with row below
df.iloc[row_idx:row_idx+2] = df.iloc[row_idx:row_idx+2].iloc[::-1].reset_index(drop=True)
reviewer_table.selection = [] # Clear selection
reviewer_table.value = df # Update the table values
# Add event handlers to move buttons
sequence_controls.objects[2][0].on_click(move_reviewer)
sequence_controls.objects[2][1].on_click(move_reviewer)
# Create form layout with sequence controls and instructions help
review_form = pn.Column(
pn.pane.Markdown("# Start Review Cycle"),
pn.pane.Markdown(f"## {self.doc_number}: {self.doc_title}"),
pn.pane.Markdown("### Select Reviewers"),
reviewer_table,
instruction_help, # Add help text for reviewer-specific instructions
sequential_check,
sequence_controls,
pn.layout.Divider(),
pn.Row(
review_type_select,
due_date_picker
),
pn.Row(
pn.Column(
pn.pane.Markdown("### Required Approval"),
approval_pct
)
),
pn.pane.Markdown("### General Instructions"),
instructions_input,
pn.Row(
cancel_btn,
start_btn,
align='end'
),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
width=500
)
# Set up event handlers
start_btn.on_click(lambda event: self._start_review_cycle(
reviewer_table=reviewer_table,
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)
# Show the form
if self.template and hasattr(self.template, 'main'):
self.tabs.clear()
self.template.main.clear()
self.template.main.append(self.notification_area)
self.template.main.append(review_form)
else:
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(review_form)
# Clear notification
self.notification_area.object = ""
except Exception as e:
logger.error(f"Error showing review form: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error showing review form:** {str(e)}"
def _start_review_cycle(self, reviewer_table, due_date, review_type, sequential,
required_approval_percentage, instructions):
"""Start a new review cycle for the current document"""
# Get selected reviewers from table with their sequence
selected_rows = reviewer_table.selection
if not selected_rows:
self.notification_area.object = "**Error:** At least one reviewer must be selected"
return
# Extract reviewer UIDs in order from the table
df = reviewer_table.value
# Extract reviewer UIDs and instructions
reviewer_uids = []
reviewer_instructions = {}
for idx in selected_rows:
row = df.iloc[idx]
uid = row['UID']
reviewer_uids.append(uid)
# Get specific instructions for this reviewer if available
if 'instructions' in row and row['instructions']:
reviewer_instructions[uid] = row['instructions']
if not reviewer_uids or len(reviewer_uids) == 0:
self.notification_area.object = "**Error:** At least one reviewer must be selected"
return
# Convert due_date string to datetime if needed
if isinstance(due_date, str):
try:
due_date = datetime.fromisoformat(due_date)
except ValueError:
self.notification_area.object = "**Error:** Invalid date format"
return
elif isinstance(due_date, date):
# Convert date to datetime at end of day
due_date = datetime.combine(due_date, datetime.max.time())
# Show processing message
self.notification_area.object = "**Starting review cycle...**"
try:
# Call controller to create review cycle with reviewer-specific instructions
result = create_review_cycle(
user=self.user,
document_uid=self.document_uid,
reviewer_uids=reviewer_uids, # Already in sequence order from table
reviewer_instructions=reviewer_instructions, # Added parameter with per-reviewer instructions
due_date=due_date,
instructions=instructions,
review_type=review_type,
sequential=sequential,
required_approval_percentage=required_approval_percentage
)
if result['success']:
self.notification_area.object = "**Success:** Review cycle started successfully"
# Reload document after a short delay
self._load_document()
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Unknown error')}"
except ResourceNotFoundError as e:
self.notification_area.object = f"**Error:** {str(e)}"
except ValidationError as e:
self.notification_area.object = f"**Validation Error:** {str(e)}"
except PermissionError as e:
self.notification_area.object = f"**Permission Error:** {str(e)}"
except BusinessRuleError as e:
self.notification_area.object = f"**Business Rule Error:** {str(e)}"
except Exception as e:
logger.error(f"Error starting review cycle: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** An unexpected error occurred"
def _start_approval_cycle(self, approver_table, due_date, approval_type, sequential,
required_approval_percentage, instructions):
"""Start a new approval cycle for the current document"""
# Get selected approvers from table with their sequence
selected_rows = approver_table.selection
if not selected_rows:
self.notification_area.object = "**Error:** At least one approver must be selected"
return
# Extract approver UIDs in order from the table
df = approver_table.value
# Extract approver UIDs and instructions
approver_uids = []
approver_instructions = {}
for idx in selected_rows:
row = df.iloc[idx]
uid = row['UID']
approver_uids.append(uid)
# Get specific instructions for this approver if available
if 'instructions' in row and row['instructions']:
approver_instructions[uid] = row['instructions']
if not approver_uids or len(approver_uids) == 0:
self.notification_area.object = "**Error:** At least one approver must be selected"
return
# Convert due_date string to datetime if needed
if isinstance(due_date, str):
try:
due_date = datetime.fromisoformat(due_date)
except ValueError:
self.notification_area.object = "**Error:** Invalid date format"
return
elif isinstance(due_date, date):
# Convert date to datetime at end of day
due_date = datetime.combine(due_date, datetime.max.time())
# Show processing message
self.notification_area.object = "**Starting approval cycle...**"
try:
# Call controller to create approval cycle with approver-specific instructions
result = create_approval_cycle(
user=self.user,
document_uid=self.document_uid,
approver_uids=approver_uids, # Already in sequence order from table
approver_instructions=approver_instructions, # Added parameter with per-approver instructions
due_date=due_date,
instructions=instructions,
approval_type=approval_type,
sequential=sequential,
required_approval_percentage=required_approval_percentage
)
if result['success']:
self.notification_area.object = "**Success:** Review cycle started successfully"
# Reload document after a short delay
self._load_document()
else:
self.notification_area.object = f"**Error:** {result.get('message', 'Unknown error')}"
except ResourceNotFoundError as e:
self.notification_area.object = f"**Error:** {str(e)}"
except ValidationError as e:
self.notification_area.object = f"**Validation Error:** {str(e)}"
except PermissionError as e:
self.notification_area.object = f"**Permission Error:** {str(e)}"
except BusinessRuleError as e:
self.notification_area.object = f"**Business Rule Error:** {str(e)}"
except Exception as e:
logger.error(f"Error starting approval 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 form to start a approval cycle"""
self.notification_area.object = "Loading approval form..."
try:
# Create form elements
from CDocs.models.user_extensions import DocUser
# Get all users who can be approvers
approvers = []
try:
# Directly query users since get_potential_approvers doesn't exist
from CDocs.db.db_operations import run_query
approvers_result = run_query(
"""
MATCH (u:User)
WHERE (u.UID <> $current_user_uid) and not('Template' in labels(u))
RETURN u.UID as uid, u.Name as name, u.Role as role, u.Department as department
ORDER BY u.Name
""",
{"current_user_uid": self.user.uid}
)
approvers = [{"uid": r["uid"], "name": r["name"], "role": r.get("role", ""),
"department": r.get("department", "")} for r in approvers_result]
except Exception as e:
# If db query fails, use a simple fallback with an empty list
logger.warning(f"Error fetching approvers: {e}")
approvers = []
# Modified: Create a DataFrame with approver data including instructions column
approver_data = []
for r in approvers:
# Handle different possible data structures
if isinstance(r, dict):
uid = r.get("uid", "")
name = r.get("name", "Unknown")
role = r.get("role", "")
display_text = f"{name} ({role})" if role else name
approver_data.append({
"Approver": display_text,
"UID": uid,
"instructions": "" # Add empty instructions field for each approver
})
else:
# Fallback for non-dictionary items
logger.warning(f"Unexpected approver data format: {type(r)}")
# Create DataFrame for approver table
df = pd.DataFrame(approver_data)
# Create tabulator with selection and instructions column
approver_table = pn.widgets.Tabulator(
df,
selection=[], # Empty list for selection
pagination='local',
page_size=6,
height=250,
width=400,
selectable='checkbox',
formatters={"instructions": {"type": "input"}},
hidden_columns=['UID']
)
# Create sequential checkbox with callback to show/hide order controls
sequential_check = pn.widgets.Checkbox(
name="Sequential Approval",
value=False
)
# Container for sequence order controls
sequence_controls = pn.Column(
pn.pane.Markdown("### Set Approval Sequence"),
pn.pane.Markdown("Select approvers and use buttons to arrange them in order:"),
pn.Row(
pn.widgets.Button(name="⬆️ Move Up", width=120, button_type="default"),
pn.widgets.Button(name="⬇️ Move Down", width=120, button_type="default"),
align='center'
),
visible=False,
styles={'background': '#f5f5f5'},
css_classes=['p-2', 'border', 'rounded']
)
# Show/hide sequence controls based on sequential checkbox
def toggle_sequence_controls(event):
sequence_controls.visible = event.new
sequential_check.param.watch(toggle_sequence_controls, 'value')
# Create due date picker (default to 2 weeks from now)
default_due_date = (datetime.now() + timedelta(days=14)).date()
due_date_picker = pn.widgets.DatePicker(
name="Due Date",
value=default_due_date,
width=200
)
# Create approval type dropdown
approval_type_select = pn.widgets.Select(
name="Approval Type",
options=settings.APPROVAL_TYPES,
value="STANDARD",
width=200
)
# Required approval percentage (default 100%)
approval_pct = pn.widgets.IntSlider(
name="Required Approval Percentage",
start=1,
end=100,
value=100,
step=1,
width=200
)
# Instructions textarea
instructions_input = pn.widgets.TextAreaInput(
name="Instructions for Approvers",
placeholder="Enter general instructions for all approvers",
width=400,
height=100
)
# Create instruction help text
instruction_help = pn.pane.Markdown("""
**Note:** You can add specific instructions for each approver by:
1. Clicking on a cell in the Instructions column
2. Typing the specific instructions for that approver
3. Pressing Enter when done
""")
# Create start button
start_btn = pn.widgets.Button(
name="Start Approval Cycle",
button_type="success",
width=150
)
# Create cancel button
cancel_btn = pn.widgets.Button(
name="Cancel",
button_type="default",
width=120
)
# Function to move approvers up/down in sequence
def move_approver(event):
selected = approver_table.selection
if not selected or len(selected) == 0: # Additional check for empty list
return
df = approver_table.value
row_idx = selected[0]
if event.obj.name == "⬆️ Move Up" and row_idx > 0:
# Swap with row above
df.iloc[row_idx-1:row_idx+1] = df.iloc[row_idx-1:row_idx+1].iloc[::-1].reset_index(drop=True)
elif event.obj.name == "⬇️ Move Down" and row_idx < len(df)-1:
# Swap with row below
df.iloc[row_idx:row_idx+2] = df.iloc[row_idx:row_idx+2].iloc[::-1].reset_index(drop=True)
approver_table.selection = [] # Clear selection
approver_table.value = df # Update the table values
# Add event handlers to move buttons
sequence_controls.objects[2][0].on_click(move_approver)
sequence_controls.objects[2][1].on_click(move_approver)
# Create form layout with sequence controls and instructions help
approval_form = pn.Column(
pn.pane.Markdown("# Start Approval Cycle"),
pn.pane.Markdown(f"## {self.doc_number}: {self.doc_title}"),
pn.pane.Markdown("### Select Approvers"),
approver_table,
instruction_help, # Add help text for approver-specific instructions
sequential_check,
sequence_controls,
pn.layout.Divider(),
pn.Row(
approval_type_select,
due_date_picker
),
pn.Row(
pn.Column(
pn.pane.Markdown("### Required Approval"),
approval_pct
)
),
pn.pane.Markdown("### General Instructions"),
instructions_input,
pn.Row(
cancel_btn,
start_btn,
align='end'
),
styles={'background':'#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
width=500
)
# Set up event handlers
start_btn.on_click(lambda event: self._start_approval_cycle(
approver_table=approver_table,
due_date=due_date_picker.value,
approval_type=approval_type_select.value,
sequential=sequential_check.value,
required_approval_percentage=approval_pct.value,
instructions=instructions_input.value
))
cancel_btn.on_click(self._load_document)
# Show the form
if self.template and hasattr(self.template, 'main'):
self.tabs.clear()
self.template.main.clear()
self.template.main.append(self.notification_area)
self.template.main.append(approval_form)
else:
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(approval_form)
# Clear notification
self.notification_area.object = ""
except Exception as e:
logger.error(f"Error showing approval form: {e}")
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error showing approval form:** {str(e)}"
def _create_approver_step(self, title,user_options):
"""Helper to create a step card for approval workflow"""
# Create a multi-select for approvers
approvers_select = pn.widgets.MultiSelect(
name='Approvers',
options=user_options,
size=5
)
# Step options
all_approve_check = pn.widgets.Checkbox(
name='All must approve',
value=True
)
options_row = pn.Row(
all_approve_check,
name='Step Options'
)
# Create step card
step_card = pn.Column(
pn.pane.Markdown(f"### {title}"),
approvers_select,
options_row,
styles={'background': '#f8f9fa'},
css_classes=['p-3', 'border', 'rounded', 'mb-3'],
sizing_mode='stretch_width'
)
# Store references directly as attributes on the object instead of using param
step_card.approvers_select = approvers_select
step_card.all_approve_check = all_approve_check
return step_card
def _get_user_options(self):
"""Get user options for approver selection"""
from CDocs.models.user_extensions import DocUser
try:
potential_approvers = DocUser.get_users_by_role(role="APPROVER")
user_options = {f"{u.name} ({u.username})" : u.uid for u in potential_approvers}
return user_options
except Exception as e:
logger.error(f"Error getting users: {e}")
return {} # Return empty dict on error
def _show_publish_form(self, event=None):
"""Show form to publish a document"""
try:
if not self.document:
self.notification_area.object = "**Error:** No document loaded"
return
# Clear notification area
self.notification_area.object = ""
# Create form elements
publish_comment = pn.widgets.TextAreaInput(
name="Publication Comment",
placeholder="Enter comment about this publication (optional)",
rows=3,
width=400
)
# Calculate next major version number from current version
current_version = self.document.get('revision', '1.0')
try:
major_version = int(current_version.split('.')[0])
next_version = f"{major_version + 1}.0"
except (ValueError, IndexError):
next_version = "1.0" # Default if parsing fails
version_info = pn.pane.Markdown(f"**Note:** This will create version **{next_version}** as the published version.")
# Create checkbox for confirmation
confirmation_checkbox = pn.widgets.Checkbox(
name="I confirm this document is ready for publication",
value=False
)
# Create submit and cancel buttons
submit_btn = pn.widgets.Button(
name="Publish Document",
button_type="success",
disabled=True,
width=150
)
cancel_btn = pn.widgets.Button(
name="Cancel",
button_type="default",
width=100
)
# Enable submit button only when confirmation checked
def toggle_submit(event):
submit_btn.disabled = not event.new
confirmation_checkbox.param.watch(toggle_submit, 'value')
# Set up callbacks
cancel_btn.on_click(lambda event: self._load_document()) # Changed to reload the document
submit_btn.on_click(lambda event: self._publish_document(
publish_comment=publish_comment.value,
form_panel=form_panel
))
# Create form layout
form_panel = pn.Column(
pn.pane.Markdown("## Publish Document"),
pn.pane.Markdown(f"You are about to publish **{self.document.get('title')}** (Document Number: {self.doc_number})"),
version_info,
pn.pane.Markdown("Publishing this document will:"),
pn.pane.Markdown("1. Create a finalized PDF with signatures and audit trail"),
pn.pane.Markdown("2. Mark the document as PUBLISHED"),
pn.pane.Markdown("3. Make it available to all authorized users"),
publish_comment,
confirmation_checkbox,
pn.Row(
submit_btn,
cancel_btn,
align='end'
),
styles={'background': '#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
width=500
)
# Clear display area and show form - THIS IS THE KEY FIX
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_panel)
else:
# Embedded mode using main_content
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(form_panel)
except Exception as e:
self.notification_area.object = f"**Error preparing publication form:** {str(e)}"
logger.error(f"Error in _show_publish_form: {e}")
logger.error(traceback.format_exc())
def _publish_document(self, publish_comment, form_panel):
"""Handle document publication process"""
try:
# Show processing message
self.notification_area.object = "**Processing:** Publishing document..."
# Remove the form
self.main_content.remove(form_panel)
# Import publication controller
from CDocs.controllers.document_controller import publish_document
# Call controller function to publish the document
result = publish_document(
user=self.user,
document_uid=self.document_uid,
publish_comment=publish_comment
)
if result.get('success'):
# Display success message with details
self.notification_area.object = f"""
**Success:** Document published successfully!
The document is now published as version {result.get('new_version_number')}.
A finalized PDF version has been created and all links will now point to this published version.
"""
# Add reload button
reload_btn = pn.widgets.Button(
name="Reload Document",
button_type="primary",
width=150
)
reload_btn.on_click(self._load_document)
# Add button below notification
button_row = pn.Row(reload_btn, align='center')
self.main_content.insert(1, button_row) # Insert after notification
else:
# Display error message
self.notification_area.object = f"**Error:** {result.get('message', 'Unknown error during publication')}"
except Exception as e:
self.notification_area.object = f"**Error publishing document:** {str(e)}"
logger.error(f"Error in _publish_document: {e}")
logger.error(traceback.format_exc())
def _show_archive_form(self, event=None):
"""Show form to archive the document"""
try:
if not self.document:
self.notification_area.object = "**Error:** No document loaded"
return
# Clear notification area
self.notification_area.object = ""
# Create form elements
archive_reason = pn.widgets.Select(
name="Archive Reason",
options=["Obsolete", "Superseded", "No Longer Required", "Regulatory Change", "Other"],
width=400
)
archive_comment = pn.widgets.TextAreaInput(
name="Archive Comment",
placeholder="Enter additional details about archiving this document",
rows=3,
width=400
)
# Create checkbox for confirmation
confirmation_checkbox = pn.widgets.Checkbox(
name="I confirm this document should be archived",
value=False
)
# Create submit and cancel buttons
submit_btn = pn.widgets.Button(
name="Archive Document",
button_type="danger",
disabled=True,
width=150
)
cancel_btn = pn.widgets.Button(
name="Cancel",
button_type="default",
width=100
)
# Enable submit button only when confirmation checked
def toggle_submit(event):
submit_btn.disabled = not event.new
confirmation_checkbox.param.watch(toggle_submit, 'value')
# Set up callbacks
cancel_btn.on_click(lambda event: self._load_document())
submit_btn.on_click(lambda event: self._archive_document(
archive_reason=archive_reason.value,
archive_comment=archive_comment.value,
form_panel=form_panel
))
# Create form layout
form_panel = pn.Column(
pn.pane.Markdown("## Archive Document"),
pn.pane.Markdown(f"You are about to archive **{self.document.get('title')}** (Document Number: {self.doc_number})"),
pn.pane.Markdown("**Warning:** This will archive all versions of the document."),
pn.pane.Markdown("Archiving this document will:"),
pn.pane.Markdown("1. Change the status of all versions to ARCHIVED"),
pn.pane.Markdown("2. Move the published PDF to an archive location"),
pn.pane.Markdown("3. Make the document unavailable for normal use"),
archive_reason,
archive_comment,
confirmation_checkbox,
pn.Row(
submit_btn,
cancel_btn,
align='end'
),
styles={'background': '#f8f9fa'},
css_classes=['p-3', 'border', 'rounded'],
width=500
)
# 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(form_panel)
else:
# Embedded mode using main_content
self.main_content.clear()
self.main_content.append(self.notification_area)
self.main_content.append(form_panel)
except Exception as e:
self.notification_area.object = f"**Error preparing archive form:** {str(e)}"
logger.error(f"Error in _show_archive_form: {e}")
logger.error(traceback.format_exc())
def _archive_document(self, archive_reason, archive_comment, form_panel):
"""Handle document archiving process"""
try:
# Validate input
if not archive_reason:
self.notification_area.object = "**Error:** Please select an archive reason"
return
# Show processing message
self.notification_area.object = "**Processing:** Archiving document..."
# Remove the form
if self.main_content and form_panel in self.main_content:
self.main_content.remove(form_panel)
# Import archive controller
from CDocs.controllers.document_controller import archive_document
# Call controller function to archive the document
result = archive_document(
user=self.user,
document_uid=self.document_uid,
archive_reason=archive_reason,
archive_comment=archive_comment
)
if result.get('success'):
# Display success message with details
self.notification_area.object = f"""
**Success:** Document archived successfully!
The document and all its versions have been archived.
The published PDF has been moved to the archive location.
"""
# Add reload button
reload_btn = pn.widgets.Button(
name="Reload Document",
button_type="primary",
width=150
)
reload_btn.on_click(self._load_document)
# Add button below notification
button_row = pn.Row(reload_btn, align='center')
self.main_content.insert(1, button_row) # Insert after notification
else:
# Display error message
self.notification_area.object = f"**Error:** {result.get('message', 'Unknown error during archiving')}"
except Exception as e:
self.notification_area.object = f"**Error archiving document:** {str(e)}"
logger.error(f"Error in _archive_document: {e}")
logger.error(traceback.format_exc())
def _show_clone_form(self, event=None):
"""Show form to clone the document with custom number option"""
try:
# Get current document information
document_uid = getattr(self, 'document_uid', None)
doc_title = getattr(self, 'doc_title', 'Unknown Document')
doc_type = getattr(self, 'doc_type', '')
department = getattr(self, 'department', '')
if not document_uid:
self.notification_area.object = "**Error:** No document selected for cloning"
return
# Clear main content and show clone form
self.main_content.clear()
self.notification_area.object = "Preparing clone form..."
# Create form widgets
title_input = pn.widgets.TextInput(
name="New Title",
value=f"Copy of {doc_title}",
width=400
)
# Get document types and departments from settings
from CDocs.config import settings
# Document type selector - use full names as options
doc_type_options = list(settings.DOCUMENT_TYPES.keys())
doc_type_select = pn.widgets.Select(
name="Document Type",
options=doc_type_options,
value=settings.get_document_type_name(doc_type) if doc_type else doc_type_options[0],
width=200
)
# Department selector - use full names as options
dept_options = list(settings.DEPARTMENTS.keys())
department_select = pn.widgets.Select(
name="Department",
options=dept_options,
value=settings.get_department_name(department) if department else dept_options[0],
width=200
)
# Custom document number option
custom_number_toggle = pn.widgets.Switch(name="Use Custom Document Number", value=False)
custom_number_input = pn.widgets.TextInput(
name="Document Number",
placeholder="e.g., CUSTOM-DOC-001",
width=300,
visible=False
)
validation_status = pn.pane.Markdown("", width=400, visible=False)
# Toggle visibility handler
def toggle_custom_number(event):
custom_number_input.visible = event.new
validation_status.visible = event.new
if not event.new:
custom_number_input.value = ""
validation_status.object = ""
custom_number_toggle.param.watch(toggle_custom_number, 'value')
# Real-time validation handler
def validate_number(event):
if event.new and len(event.new.strip()) > 0:
try:
from CDocs.controllers.document_controller import validate_document_number
result = validate_document_number(event.new.strip().upper())
if result.get('valid', False):
validation_status.object = f"✅ {result.get('message', 'Valid')}"
validation_status.styles = {'color': 'green'}
else:
validation_status.object = f"❌ {result.get('message', 'Invalid')}"
validation_status.styles = {'color': 'red'}
except Exception as e:
validation_status.object = f"❌ Error validating: {str(e)}"
validation_status.styles = {'color': 'red'}
else:
validation_status.object = ""
custom_number_input.param.watch(validate_number, 'value')
# Clone type selector - FIXED: Remove the 'inline' parameter
clone_type = pn.widgets.RadioButtonGroup(
name="Clone Type",
options=[
("New Document", "new_document"),
("New Revision", "new_revision")
],
value="new_document"
)
# Content options
include_content = pn.widgets.Checkbox(name="Include Current Content", value=True)
include_comments = pn.widgets.Checkbox(name="Include Comments/Reviews", value=False)
include_metadata = pn.widgets.Checkbox(name="Include Custom Metadata", value=True)
# Comment for clone operation
comment_input = pn.widgets.TextAreaInput(
name="Clone Comment",
placeholder="Reason for cloning (optional)",
rows=3,
width=400
)
# Additional properties
additional_props = pn.widgets.TextAreaInput(
name="Additional Properties (JSON)",
placeholder='{"custom_field": "value"}',
rows=3,
width=400
)
# Buttons
submit_btn = pn.widgets.Button(name="Clone Document", button_type="success", width=150)
cancel_btn = pn.widgets.Button(name="Cancel", button_type="default", width=100)
# Create clone form layout
clone_form = pn.Column(
pn.pane.Markdown(f"# Clone Document: {doc_title}"),
pn.pane.Markdown(f"**Source Document:** {getattr(self, 'doc_number', 'Unknown')} - {doc_title}"),
pn.Spacer(height=10),
pn.pane.Markdown("### Basic Information"),
title_input,
pn.Row(doc_type_select, department_select),
pn.Spacer(height=10),
pn.pane.Markdown("### Document Number"),
custom_number_toggle,
custom_number_input,
validation_status,
pn.Spacer(height=10),
pn.pane.Markdown("### Clone Options"),
clone_type,
pn.Column(
include_content,
include_comments,
include_metadata,
margin=(10, 0, 0, 20)
),
pn.Spacer(height=10),
pn.pane.Markdown("### Comments & Properties"),
comment_input,
additional_props,
pn.Spacer(height=20),
pn.Row(
cancel_btn,
pn.Spacer(width=20),
submit_btn,
align='end'
),
width=700,
styles={'background': '#f8f9fa', 'padding': '20px', 'border-radius': '5px'},
margin=(20, 20, 20, 20)
)
# Define submit handler
def submit_clone(event):
try:
# Validate form
if not title_input.value.strip():
self.notification_area.object = "**Error:** Document title is required"
return
# Validate custom document number if provided
custom_doc_number = None
if custom_number_toggle.value and custom_number_input.value:
custom_doc_number = custom_number_input.value.strip().upper()
# Final validation
try:
from CDocs.controllers.document_controller import validate_document_number
validation_result = validate_document_number(custom_doc_number)
if not validation_result.get('valid', False):
self.notification_area.object = f"**Error:** {validation_result.get('message', 'Invalid document number')}"
return
except Exception as e:
self.notification_area.object = f"**Error:** Failed to validate document number: {str(e)}"
return
# Parse additional properties if provided
additional_properties = {}
if additional_props.value.strip():
try:
import json
additional_properties = json.loads(additional_props.value)
except json.JSONDecodeError as e:
self.notification_area.object = f"**Error:** Invalid JSON in additional properties: {str(e)}"
return
# Convert full names to codes
from CDocs.config import settings
doc_type_full_name = doc_type_select.value
doc_type_code = settings.get_document_type_code(doc_type_full_name)
dept_full_name = department_select.value
department_code = settings.get_department_code(dept_full_name)
# Determine clone type and parameters
clone_as_new_revision = (clone_type.value == "new_revision")
# Add clone options to additional properties
if comment_input.value.strip():
additional_properties['clone_comment'] = comment_input.value.strip()
additional_properties.update({
'include_content': include_content.value,
'include_comments': include_comments.value,
'include_metadata': include_metadata.value,
'clone_source_uid': document_uid,
'clone_source_title': doc_title
})
# Show progress
self.notification_area.object = "**Cloning document...**"
# Call clone function
from CDocs.controllers.document_controller import clone_document
result = clone_document(
user=self.user,
document_uid=document_uid,
new_title=title_input.value.strip(),
doc_type=doc_type_code,
department=department_code,
include_content=include_content.value,
clone_as_new_revision=clone_as_new_revision,
additional_properties=additional_properties,
custom_doc_number=custom_doc_number # Pass custom number
)
if result.get('success', False):
# Get the new document information
new_doc_uid = result.get('document_uid')
new_doc_number = result.get('document_number', 'Unknown')
# Show success message
success_msg = f"**Success:** Document cloned as {new_doc_number}"
if custom_doc_number:
success_msg += f" (custom number)"
if clone_as_new_revision:
success_msg += f" (new revision)"
self.notification_area.object = success_msg
# Update document permissions for the new document
try:
from CDocs.controllers.share_controller import manage_document_permissions
from CDocs.models.document import ControlledDocument
new_document = ControlledDocument(uid=new_doc_uid)
permission_result = manage_document_permissions(new_document)
logger.debug(f"Permission management result for cloned document: {permission_result}")
except Exception as perm_error:
logger.warning(f"Error managing permissions for cloned document: {perm_error}")
# Don't fail the whole operation for permission errors
# Navigate to the new document or back to dashboard
if new_doc_uid and hasattr(self, 'parent_app') and self.parent_app:
# Option 1: Navigate to the cloned document
self.parent_app.load_document(new_doc_uid)
else:
# Option 2: Go back to dashboard
if hasattr(self, 'parent_app') and self.parent_app:
self.parent_app.show_dashboard()
else:
self._load_document()
else:
# Show error message
error_msg = result.get('message', 'Unknown error occurred')
self.notification_area.object = f"**Error:** Failed to clone document: {error_msg}"
except Exception as e:
logger.error(f"Error cloning document: {e}")
import traceback
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error cloning document:** {str(e)}"
def cancel_clone(event):
# Return to document view
self.notification_area.object = ""
self._load_document()
# Bind handlers
submit_btn.on_click(submit_clone)
cancel_btn.on_click(cancel_clone)
# Add form to main content
self.main_content.append(clone_form)
# Clear notification after form is shown
self.notification_area.object = ""
except Exception as e:
logger.error(f"Error showing clone form: {e}")
import traceback
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error showing clone form:** {str(e)}"
# Return to document view on error
self._load_document()
def _set_as_current_version(self, event):
"""Set the selected version as the current version"""
logger = logging.getLogger('CDocs.ui.document_detail')
# Debug the stored version UID
logger.debug(f"_set_as_current_version called, stored version UID: {getattr(self, '_selected_version_uid', 'None')}")
if not hasattr(self, '_selected_version_uid') or not self._selected_version_uid:
self.notification_area.object = "**Error:** No version selected"
return
try:
# Get version UID
version_uid = self._selected_version_uid
logger.debug(f"Setting version {version_uid} as current")
# Show in progress message
self.notification_area.object = "**Setting as current version...**"
# Use controller to set current version
from CDocs.controllers.document_controller import set_current_version
result = set_current_version(
user=self.user,
document_uid=self.document_uid,
version_uid=version_uid
)
if result and result.get('success'):
self.notification_area.object = "**Success:** Version set as current"
# Reload document to reflect the changes
self._load_document()
else:
error_msg = "Unknown error"
if result and 'message' in result:
error_msg = result['message']
self.notification_area.object = f"**Error:** Could not set as current version: {error_msg}"
except Exception as e:
logger.error(f"Error setting current version: {e}")
import traceback
logger.error(traceback.format_exc())
self.notification_area.object = f"**Error:** {str(e)}"
def _download_selected_version(self, event):
"""Download the selected document version"""
logger = logging.getLogger('CDocs.ui.document_detail')
if not hasattr(self, '_selected_version_uid') or not self._selected_version_uid:
self.notification_area.object = "**Error:** No version selected"
return
try:
# Get version UID
version_uid = self._selected_version_uid
logger.debug(f"Downloading version: {version_uid}")
# Show download in progress message
self.notification_area.object = "**Preparing download...**"
# Import the necessary functions
from CDocs.controllers.document_controller import download_document_version
from panel.widgets import FileDownload
# Create a callback function that will fetch the file when the download button is clicked
def get_file_content():
try:
# Get document content with all required parameters
doc_content = download_document_version(
user=self.user,
document_uid=self.document_uid,
version_uid=version_uid
)
if isinstance(doc_content, dict) and 'content' in doc_content and doc_content['content']:
# Return ONLY the BytesIO object, not a tuple
file_name = doc_content.get('file_name', 'document.pdf')
return io.BytesIO(doc_content['content'])
else:
# Handle error
self.notification_area.object = "**Error:** Could not download document"
return None
except Exception as e:
logger.error(f"Error downloading version: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
self.notification_area.object = f"**Error:** {str(e)}"
return None
# Find the version details to get the filename
file_name = "document.pdf" # Default
logger.info("version details: ", self.document)
for version in self.document.get('versions', []):
if version.get('UID') == version_uid:
file_name = version.get('file_name', file_name)
break
# Create download widget with separate filename parameter
download_widget = FileDownload(
callback=get_file_content,
filename=file_name, # Set filename separately
button_type="success",
label=f"Download {file_name}"
)
# Update the version action area to show the download widget
if hasattr(self, '_version_action_area'):
# Find the button row in the action area
for idx, obj in enumerate(self._version_action_area):
if isinstance(obj, pn.Row) and any(isinstance(child, pn.widgets.Button) for child in obj):
# Replace existing download button with our FileDownload widget
new_row = pn.Row(*[child for child in obj if not (isinstance(child, pn.widgets.Button)
and child.name == "Download Version")])
new_row.append(download_widget)
self._version_action_area[idx] = new_row
break
else:
# If no button row found, add the download widget at the end
self._version_action_area.append(download_widget)
self.notification_area.object = "**Ready for download.** Click the download button to save the file."
else:
# If no action area exists, show download widget in notification area
self.notification_area.object = pn.Column(
"**Ready for download:**",
download_widget
)
except Exception as e:
logger.error(f"Error setting up download: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
self.notification_area.object = f"**Error:** {str(e)}"
Parameters
| Name | Type | Default | Kind |
|---|---|---|---|
bases |
param.Parameterized | - |
Parameter Details
bases: Parameter of type param.Parameterized
Return Value
Returns unspecified type
Class Interface
Methods
__init__(self, parent_app)
Purpose: Internal method: init
Parameters:
parent_app: Parameter
Returns: None
set_user(self, user)
Purpose: Set the current user.
Parameters:
user: Parameter
Returns: None
load_document(self, document_uid, doc_number)
Purpose: Load document by UID or document number.
Parameters:
document_uid: Parameterdoc_number: Parameter
Returns: None
get_document_view(self)
Purpose: Get the document view for embedding in other panels.
Returns: None
_get_current_user(self) -> DocUser
Purpose: Get the current user from session
Returns: Returns DocUser
_setup_header(self)
Purpose: Set up the header with title and actions
Returns: None
_setup_sidebar(self)
Purpose: Set up the sidebar with document actions
Returns: None
_setup_main_area(self)
Purpose: Set up the main area with document content tabs
Returns: None
_load_document(self, event)
Purpose: Load document data and update the UI.
Parameters:
event: Parameter
Returns: None
_extract_document_properties(self)
Purpose: Extract document properties from document data.
Returns: None
_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
_manage_training(self, event)
Purpose: Navigate to training management for this document.
Parameters:
event: Parameter
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
_compare_versions(self, versions_table, versions_df)
Purpose: Compare two selected document versions using LLM. Args: versions_table: The tabulator widget containing the selections versions_df: The DataFrame with version data
Parameters:
versions_table: Parameterversions_df: Parameter
Returns: None
_get_version_content(self, version_uid)
Purpose: Get the content of a specific document version as text. Args: version_uid: UID of the version Returns: Document content as text or None if retrieval fails
Parameters:
version_uid: Parameter
Returns: See docstring for return details
_extract_text_from_pdf(self, file_content)
Purpose: Extract text from PDF content
Parameters:
file_content: Parameter
Returns: None
_extract_text_from_word(self, file_content, file_ext)
Purpose: Extract text from Word document content
Parameters:
file_content: Parameterfile_ext: Parameter
Returns: None
_extract_text_from_powerpoint(self, file_content, file_ext)
Purpose: Extract text from PowerPoint content
Parameters:
file_content: Parameterfile_ext: Parameter
Returns: None
_extract_text_from_excel(self, file_content, file_ext)
Purpose: Extract text from Excel content
Parameters:
file_content: Parameterfile_ext: Parameter
Returns: None
_convert_to_pdf(self, input_file)
Purpose: Convert a document to PDF using LibreOffice
Parameters:
input_file: Parameter
Returns: None
_show_comparison_results(self, comparison_result, version_numbers)
Purpose: Display comparison results in a custom modal dialog using FloatPanel for Panel 1.6.1 compatibility. Args: comparison_result: Results from the comparison version_numbers: Version numbers that were compared
Parameters:
comparison_result: Parameterversion_numbers: Parameter
Returns: None
floater_close(self, event)
Purpose: Close a floating panel Args: event: The event object containing the window and floater properties
Parameters:
event: Parameter
Returns: None
_create_versions_tab(self)
Purpose: Create a tab for document versions, with version selection and comparison functionality
Returns: None
_edit_document_online(self, event)
Purpose: Get edit URL from FileCloud and open it
Parameters:
event: Parameter
Returns: None
_convert_to_pdf(self, event)
Purpose: Convert the current document version to PDF
Parameters:
event: Parameter
Returns: None
_create_reviews_tab(self)
Purpose: Create the reviews tab content, hiding reviews for archived versions except current one
Returns: None
_is_archived_version(self, version_uid)
Purpose: Check if a version is archived
Parameters:
version_uid: Parameter
Returns: None
_review_selected(self, event)
Purpose: Handle review selection from table with support for both selection and cell click events
Parameters:
event: Parameter
Returns: None
_close_review_cycle(self, event, review_uid, update_status_checkbox, status_select)
Purpose: Close a review cycle and optionally update document status
Parameters:
event: Parameterreview_uid: Parameterupdate_status_checkbox: Parameterstatus_select: Parameter
Returns: None
_close_approval_cycle(self, event, approval_uid, update_status_checkbox, status_select)
Purpose: Close a review cycle and optionally update document status
Parameters:
event: Parameterapproval_uid: Parameterupdate_status_checkbox: Parameterstatus_select: Parameter
Returns: None
_show_add_approver_form(self, approval_uid)
Purpose: Show form to add an approver to an active approval cycle
Parameters:
approval_uid: Parameter
Returns: None
_show_extend_approval_deadline_form(self, approval_uid)
Purpose: Show form to extend approval deadline
Parameters:
approval_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
_show_cancel_approval_form(self, approval_uid)
Purpose: Show form to cancel an active approval cycle
Parameters:
approval_uid: Parameter
Returns: None
_load_review_details_by_uid(self, review_uid)
Purpose: Helper method to refresh review details after an action
Parameters:
review_uid: Parameter
Returns: None
_convert_neo4j_datetimes(self, data)
Purpose: Recursively convert all Neo4j DateTime objects to Python datetime objects or strings. Args: data: Any data structure potentially containing Neo4j DateTime objects Returns: Same data structure with Neo4j DateTime objects converted to Python datetime
Parameters:
data: Parameter
Returns: See docstring for return details
_create_approvals_tab(self)
Purpose: Create the approvals tab content, hiding approvals for archived versions except current one
Returns: None
_get_status_color(self, status)
Purpose: Get color for a status value
Parameters:
status: 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 with support for both selection and cell click events
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_input)
Purpose: Format date string or object for display, handling multiple input formats. Args: date_input: Date input which could be: - String in various formats - datetime.datetime object - Neo4j DateTime object - None Returns: Formatted date string (YYYY-MM-DD) or None if input is invalid
Parameters:
date_input: Parameter
Returns: See docstring for return details
_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_table, due_date, review_type, sequential, required_approval_percentage, instructions)
Purpose: Start a new review cycle for the current document
Parameters:
reviewer_table: Parameterdue_date: Parameterreview_type: Parametersequential: Parameterrequired_approval_percentage: Parameterinstructions: Parameter
Returns: None
_start_approval_cycle(self, approver_table, due_date, approval_type, sequential, required_approval_percentage, instructions)
Purpose: Start a new approval cycle for the current document
Parameters:
approver_table: Parameterdue_date: Parameterapproval_type: Parametersequential: Parameterrequired_approval_percentage: Parameterinstructions: Parameter
Returns: None
_show_approval_form(self, event)
Purpose: Show form to start a approval cycle
Parameters:
event: Parameter
Returns: None
_create_approver_step(self, title, user_options)
Purpose: Helper to create a step card for approval workflow
Parameters:
title: Parameteruser_options: Parameter
Returns: None
_get_user_options(self)
Purpose: Get user options for approver selection
Returns: None
_show_publish_form(self, event)
Purpose: Show form to publish a document
Parameters:
event: Parameter
Returns: None
_publish_document(self, publish_comment, form_panel)
Purpose: Handle document publication process
Parameters:
publish_comment: Parameterform_panel: Parameter
Returns: None
_show_archive_form(self, event)
Purpose: Show form to archive the document
Parameters:
event: Parameter
Returns: None
_archive_document(self, archive_reason, archive_comment, form_panel)
Purpose: Handle document archiving process
Parameters:
archive_reason: Parameterarchive_comment: Parameterform_panel: Parameter
Returns: None
_show_clone_form(self, event)
Purpose: Show form to clone the document with custom number option
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 98.7% similar
-
class DocumentDetail 97.9% similar
-
function create_document_detail 57.4% similar
-
class DocumentDashboard 55.4% similar
-
class TrainingManagement 53.1% similar