Spaces:
Sleeping
Sleeping
| """Rubric Preview Modal component for displaying full rubric layout.""" | |
| from nicegui import ui, app | |
| from openpyxl import Workbook | |
| from openpyxl.styles import Font, Alignment, PatternFill, Border, Side, Color | |
| from openpyxl.utils import get_column_letter | |
| from openpyxl.cell.text import InlineFont | |
| from openpyxl.cell.rich_text import TextBlock, CellRichText | |
| import io | |
| import base64 | |
| import re | |
| from html.parser import HTMLParser | |
| class HTMLToRichText(HTMLParser): | |
| """Parse HTML and convert to Excel rich text with formatting.""" | |
| def __init__(self): | |
| super().__init__() | |
| self.result = [] | |
| self.current_text = "" | |
| self.bold_stack = 0 | |
| self.italic_stack = 0 | |
| self.underline_stack = 0 | |
| self.color_stack = [] | |
| def handle_starttag(self, tag, attrs): | |
| # For block-level elements, add text before and potentially add newline after | |
| if tag in ['p', 'div', 'br']: | |
| if self.current_text: | |
| self._add_text() | |
| # For br, add a newline | |
| if tag == 'br': | |
| self.current_text = "\n" | |
| return | |
| # Save any accumulated text before processing tag | |
| if self.current_text: | |
| self._add_text() | |
| if tag in ['b', 'strong']: | |
| self.bold_stack += 1 | |
| elif tag in ['i', 'em']: | |
| self.italic_stack += 1 | |
| elif tag == 'u': | |
| self.underline_stack += 1 | |
| elif tag == 'font': | |
| # Handle <font color="#RRGGBB"> tag (Quasar editor format) | |
| color_found = False | |
| for attr, value in attrs: | |
| if attr == 'color': | |
| # Remove leading # if present | |
| color_hex = value.lstrip('#') | |
| # Validate it's a hex color | |
| if re.match(r'^[0-9a-fA-F]{6}$', color_hex): | |
| self.color_stack.append(color_hex) | |
| color_found = True | |
| # If no color found, push None to keep stack balanced | |
| if not color_found: | |
| self.color_stack.append(None) | |
| elif tag == 'span': | |
| # Look for style attribute with color | |
| color_found = False | |
| for attr, value in attrs: | |
| if attr == 'style': | |
| # Extract color from style="color: #RRGGBB" or style="color: rgb(r, g, b)" | |
| color_match = re.search(r'color:\s*#([0-9a-fA-F]{6})', value) | |
| if color_match: | |
| color_hex = color_match.group(1) | |
| self.color_stack.append(color_hex) | |
| color_found = True | |
| else: | |
| # Try rgb format | |
| rgb_match = re.search(r'color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)', value) | |
| if rgb_match: | |
| r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)) | |
| color_hex = f"{r:02X}{g:02X}{b:02X}" | |
| self.color_stack.append(color_hex) | |
| color_found = True | |
| # If no color found, push None to keep stack balanced | |
| if not color_found: | |
| self.color_stack.append(None) | |
| def handle_endtag(self, tag): | |
| # For block-level elements, add newline after closing | |
| if tag in ['p', 'div']: | |
| if self.current_text: | |
| self._add_text() | |
| # Add newline after paragraph/div | |
| self.current_text = "\n" | |
| return | |
| # Save any accumulated text before processing tag | |
| if self.current_text: | |
| self._add_text() | |
| if tag in ['b', 'strong']: | |
| self.bold_stack = max(0, self.bold_stack - 1) | |
| elif tag in ['i', 'em']: | |
| self.italic_stack = max(0, self.italic_stack - 1) | |
| elif tag == 'u': | |
| self.underline_stack = max(0, self.underline_stack - 1) | |
| elif tag in ['font', 'span'] and self.color_stack: | |
| self.color_stack.pop() | |
| def handle_data(self, data): | |
| self.current_text += data | |
| def _add_text(self): | |
| if not self.current_text: | |
| return | |
| # Create font for this text segment | |
| font_kwargs = {} | |
| if self.bold_stack > 0: | |
| font_kwargs['b'] = True | |
| if self.italic_stack > 0: | |
| font_kwargs['i'] = True | |
| if self.underline_stack > 0: | |
| font_kwargs['u'] = 'single' | |
| # Find the most recent non-None color | |
| active_color = None | |
| for color in reversed(self.color_stack): | |
| if color is not None: | |
| active_color = color | |
| break | |
| if active_color: | |
| color_hex = active_color.upper() | |
| # Remove alpha if present (first 2 chars if 8 chars long) | |
| if len(color_hex) == 8: | |
| color_hex = color_hex[2:] | |
| font_kwargs['color'] = Color(rgb=color_hex) | |
| if font_kwargs: | |
| try: | |
| font = InlineFont(**font_kwargs) | |
| self.result.append(TextBlock(font, self.current_text)) | |
| except Exception: | |
| # Fall back to plain text if formatting fails | |
| self.result.append(self.current_text) | |
| else: | |
| self.result.append(self.current_text) | |
| self.current_text = "" | |
| def get_rich_text(self): | |
| # Add any remaining text | |
| if self.current_text: | |
| self._add_text() | |
| if len(self.result) == 0: | |
| return "" | |
| elif len(self.result) == 1 and isinstance(self.result[0], str): | |
| # If only plain text, return as string | |
| return self.result[0] | |
| else: | |
| # Return as CellRichText | |
| try: | |
| return CellRichText(self.result) | |
| except Exception: | |
| # Fall back to plain text if rich text creation fails | |
| plain_text = "".join(str(part.text) if isinstance(part, TextBlock) else str(part) for part in self.result) | |
| return plain_text | |
| class RubricPreviewModal: | |
| """ | |
| Modal component that displays the full rubric preview. | |
| Shows static scoring headers and dynamic prompt group rows. | |
| """ | |
| def __init__(self, rubric_data: dict): | |
| """ | |
| Initialize the modal with rubric data. | |
| Args: | |
| rubric_data: The rubric_data JSON from the projects table | |
| """ | |
| self.rubric_data = rubric_data or {} | |
| self.dialog = None | |
| self.show_header_details = False # Reactive state for collapsible descriptions | |
| def _get_scoring_levels(self) -> list: | |
| """Get the scoring levels from rubric_data.""" | |
| scoring_data = self.rubric_data.get("scoring_levels_data", {}) | |
| return scoring_data.get("levels", []) | |
| def _get_main_header(self) -> str: | |
| """Get the main scoring header text.""" | |
| scoring_data = self.rubric_data.get("scoring_levels_data", {}) | |
| return scoring_data.get("main_header", "Scoring Notes and Sample Student Responses") | |
| def _get_prompt_groups(self) -> list: | |
| """Get the prompt groups array.""" | |
| return self.rubric_data.get("prompt_groups", []) | |
| def _get_scoring_for_level(self, group_scoring_data: list, level: int) -> dict: | |
| """ | |
| Extract scoring data for a specific level from group_scoring_data. | |
| Args: | |
| group_scoring_data: List of scoring entries for the prompt group | |
| level: The scoring level (0-3) | |
| Returns: | |
| Dict with level, description, and sample_responses fields | |
| """ | |
| for entry in group_scoring_data: | |
| if entry.get("level") == level: | |
| return entry | |
| return {"level": level, "description": "", "sample_responses": ""} | |
| def _render_empty_state(self): | |
| """Render message when no rubric data exists.""" | |
| with ui.element('div').classes('flex flex-col items-center justify-center py-12'): | |
| ui.icon('description', size='xl').classes('text-slate-300 mb-4') | |
| ui.label('No rubric data yet').classes('text-lg text-slate-500 mb-2') | |
| ui.label('Complete Stage 1 to generate the rubric skeleton.').classes('text-sm text-slate-400') | |
| def _format_items_content(self, items: list) -> None: | |
| """Render the items with their prompts and responses.""" | |
| if not items: | |
| ui.label('—').classes('text-slate-400') | |
| return | |
| for i, item in enumerate(items): | |
| with ui.element('div').classes('mb-3 pb-3 border-b border-slate-200 last:border-b-0 last:mb-0 last:pb-0'): | |
| # Item header with ID | |
| ui.label(f"Item {item.get('id', i+1)}").classes('text-xs font-semibold text-primary mb-1') | |
| # Prompt text | |
| prompt_text = item.get('prompt_text', '') | |
| if prompt_text: | |
| ui.label(prompt_text).classes('text-sm text-slate-700 mb-2') | |
| # Response options or ideal response | |
| options = item.get('options', []) | |
| correct_answer = item.get('correct_answer_text', '') | |
| if options: | |
| # Multiple choice - show options with correct one highlighted | |
| ui.label('Options:').classes('text-xs font-medium text-slate-500 mb-1') | |
| for option in options: | |
| is_correct = self._is_correct_option(option, correct_answer) | |
| option_classes = 'text-xs ml-2 ' | |
| if is_correct: | |
| option_classes += 'text-green-600 font-medium' | |
| else: | |
| option_classes += 'text-slate-600' | |
| ui.label(option).classes(option_classes) | |
| elif correct_answer: | |
| # Open response - show ideal response | |
| ui.label('Ideal Response:').classes('text-xs font-medium text-slate-500 mb-1') | |
| ui.label(correct_answer).classes('text-xs text-blue-600 ml-2') | |
| def _is_correct_option(self, option: str, correct_answer_text: str) -> bool: | |
| """Check if an option matches the correct answer text.""" | |
| if not correct_answer_text: | |
| return False | |
| import re | |
| cleaned_option = re.sub(r'^[A-Z][.\)]\s*', '', option) | |
| return cleaned_option.strip() == correct_answer_text.strip() | |
| def _render_scoring_cell(self, group_scoring_data: list, level: int): | |
| """ | |
| Render a scoring cell with upper (description) and lower (sample_responses) sections. | |
| Args: | |
| group_scoring_data: List of scoring entries for the prompt group | |
| level: The scoring level (0-3) | |
| """ | |
| entry = self._get_scoring_for_level(group_scoring_data, level) | |
| description = entry.get("description", "") | |
| sample_responses = entry.get("sample_responses", "") | |
| with ui.element('td').classes( | |
| 'border border-slate-300 dark:border-slate-600 p-0 min-w-48 align-top' | |
| ): | |
| # Always show split layout structure | |
| with ui.element('div').classes('flex flex-col h-full divide-y divide-slate-200 dark:divide-slate-600'): | |
| # Upper section: Description | |
| with ui.element('div').classes( | |
| 'px-3 py-2 bg-blue-50 dark:bg-blue-900/20 min-h-16' | |
| ): | |
| ui.label('Criteria:').classes( | |
| 'text-xs font-semibold text-blue-600 dark:text-blue-400 mb-1' | |
| ) | |
| if description: | |
| ui.label(description).classes( | |
| 'text-sm text-slate-700 dark:text-slate-300' | |
| ) | |
| else: | |
| ui.label('Not yet defined').classes( | |
| 'text-sm text-slate-400 italic' | |
| ) | |
| # Lower section: Sample Responses | |
| with ui.element('div').classes( | |
| 'px-3 py-2 bg-green-50 dark:bg-green-900/20 min-h-16 flex-1' | |
| ): | |
| ui.label('Sample Responses:').classes( | |
| 'text-xs font-semibold text-green-600 dark:text-green-400 mb-1' | |
| ) | |
| if sample_responses: | |
| ui.label(sample_responses).classes( | |
| 'text-sm text-slate-600 dark:text-slate-400 italic' | |
| ) | |
| else: | |
| ui.label('No examples yet').classes( | |
| 'text-sm text-slate-400 italic' | |
| ) | |
| def _render_table_header(self): | |
| """Render the sticky table header.""" | |
| levels = self._get_scoring_levels() | |
| main_header = self._get_main_header() | |
| num_levels = len(levels) | |
| with ui.element('thead').classes('sticky top-0 z-10 bg-white dark:bg-slate-900'): | |
| # === HEADER ROW 1: Main column headers === | |
| with ui.element('tr').classes('bg-slate-100 dark:bg-slate-700'): | |
| # Static columns | |
| with ui.element('th').classes( | |
| 'border border-slate-300 dark:border-slate-600 px-3 py-2 text-left font-semibold bg-slate-100 dark:bg-slate-700' | |
| ).props('rowspan="2"'): | |
| ui.label('Prompt Group') | |
| with ui.element('th').classes( | |
| 'border border-slate-300 dark:border-slate-600 px-3 py-2 text-left font-semibold min-w-64 bg-slate-100 dark:bg-slate-700' | |
| ).props('rowspan="2"'): | |
| ui.label('Prompts and Hypothetical Complete Response') | |
| with ui.element('th').classes( | |
| 'border border-slate-300 dark:border-slate-600 px-3 py-2 text-left font-semibold bg-slate-100 dark:bg-slate-700' | |
| ).props('rowspan="2"'): | |
| ui.label('Alignment and Prompt Group Performance Expectation') | |
| with ui.element('th').classes( | |
| 'border border-slate-300 dark:border-slate-600 px-3 py-2 text-left font-semibold bg-slate-100 dark:bg-slate-700' | |
| ).props('rowspan="2"'): | |
| ui.label('Purpose and Notes') | |
| # Scoring header spanning all level columns | |
| with ui.element('th').classes( | |
| 'border border-slate-300 dark:border-slate-600 px-3 py-2 text-center font-semibold bg-indigo-50 dark:bg-indigo-900' | |
| ).props(f'colspan="{num_levels}"'): | |
| ui.label(main_header) | |
| # === HEADER ROW 2: Scoring level subheaders === | |
| with ui.element('tr').classes('bg-slate-50 dark:bg-slate-800'): | |
| for level in levels: | |
| with ui.element('th').classes( | |
| 'border border-slate-300 dark:border-slate-600 px-3 py-2 text-left min-w-48 bg-slate-50 dark:bg-slate-800' | |
| ): | |
| ui.label(level['title']).classes('font-semibold text-sm') | |
| # Conditionally show generic_descriptor based on toggle state | |
| if self.show_header_details: | |
| ui.label(level['generic_descriptor']).classes( | |
| 'text-xs text-slate-500 dark:text-slate-400 mt-1 font-normal' | |
| ) | |
| def _render_table_body(self): | |
| """Render the table body with prompt group rows.""" | |
| levels = self._get_scoring_levels() | |
| groups = self._get_prompt_groups() | |
| num_levels = len(levels) | |
| with ui.element('tbody'): | |
| if not groups: | |
| # No groups - show placeholder row | |
| with ui.element('tr'): | |
| with ui.element('td').classes( | |
| 'border border-slate-300 dark:border-slate-600 px-3 py-8 text-center text-slate-400' | |
| ).props(f'colspan="{4 + num_levels}"'): | |
| ui.label('No prompt groups defined yet') | |
| else: | |
| for group in groups: | |
| with ui.element('tr').classes('hover:bg-slate-50 dark:hover:bg-slate-800'): | |
| # Prompt Group title | |
| with ui.element('td').classes( | |
| 'border border-slate-300 dark:border-slate-600 px-3 py-3 font-medium align-top' | |
| ): | |
| ui.label(group.get('title', '')) | |
| # Prompts and Hypothetical Complete Response | |
| with ui.element('td').classes( | |
| 'border border-slate-300 dark:border-slate-600 px-3 py-3 align-top min-w-64' | |
| ): | |
| self._format_items_content(group.get('items', [])) | |
| # Alignment - Split into alignment_data (top) and alignment text (bottom) | |
| with ui.element('td').classes( | |
| 'border border-slate-300 dark:border-slate-600 p-0 align-top' | |
| ): | |
| alignment_data = group.get('alignment_data', []) | |
| alignment_text = group.get('alignment', '') | |
| # Flex column layout with two sections | |
| with ui.element('div').classes('flex flex-col divide-y divide-slate-200 dark:divide-slate-600'): | |
| # Top Section: alignment_data with type-based color coding | |
| with ui.element('div').classes('px-3 py-3 bg-slate-50 dark:bg-slate-800 min-h-12'): | |
| ui.label('Standards:').classes( | |
| 'text-xs font-semibold text-slate-600 dark:text-slate-400 mb-2' | |
| ) | |
| if alignment_data: | |
| # Render each standard with color coding based on type | |
| with ui.element('div').classes('flex flex-col gap-2'): | |
| for item in alignment_data: | |
| standard_type = item.get('type', 'dci') | |
| code = item.get('code', '') | |
| html_content = item.get('html_content', item.get('text', '')) | |
| # Get colors based on type | |
| if standard_type == 'dci': | |
| bg_color = '#fff8f0' | |
| border_color = '#ff9900' | |
| elif standard_type == 'sep': | |
| bg_color = '#f0f5ff' | |
| border_color = '#4a86e8' | |
| elif standard_type == 'ccc': | |
| bg_color = '#f0fff4' | |
| border_color = '#6aa84f' | |
| else: | |
| bg_color = '#f9f9f9' | |
| border_color = '#ccc' | |
| # Render card with type-based styling | |
| with ui.element('div').classes('px-2 py-2 rounded border').style( | |
| f'background-color: {bg_color}; border-color: {border_color}; border-width: 2px;' | |
| ): | |
| # Format: <b>code</b>: html_content (all text uses border_color) | |
| display_html = f'<span style="color: {border_color};"><b>{code}</b>: {html_content}</span>' | |
| ui.html(display_html, sanitize=False).classes('text-xs leading-relaxed') | |
| else: | |
| ui.label('—').classes('text-sm text-slate-400 italic') | |
| # Bottom Section: alignment statement text (with HTML rendering) | |
| with ui.element('div').classes('px-3 py-3 bg-white dark:bg-slate-900 min-h-12 flex-1'): | |
| ui.label('Performance Expectation:').classes( | |
| 'text-xs font-semibold text-slate-600 dark:text-slate-400 mb-2' | |
| ) | |
| if alignment_text: | |
| # Check if content contains HTML tags | |
| if '<' in alignment_text and '>' in alignment_text: | |
| # Render as HTML to preserve color formatting | |
| ui.html(alignment_text, sanitize=False).classes('text-sm text-slate-700 dark:text-slate-300') | |
| else: | |
| # Plain text - render as label for backward compatibility | |
| ui.label(alignment_text).classes('text-sm text-slate-700 dark:text-slate-300') | |
| else: | |
| ui.label('—').classes('text-sm text-slate-400 italic') | |
| # Purpose (empty for Stage 1) | |
| with ui.element('td').classes( | |
| 'border border-slate-300 dark:border-slate-600 px-3 py-3 text-slate-400 align-top' | |
| ): | |
| ui.label(group.get('purpose', '') or '—') | |
| # Scoring cells with split layout | |
| group_scoring_data = group.get('group_scoring_data', []) | |
| for level_info in levels: | |
| level_num = level_info.get('level', 0) | |
| self._render_scoring_cell(group_scoring_data, level_num) | |
| def _toggle_header_details(self, toggle_button, table_container): | |
| """Toggle the visibility of header descriptions and refresh table.""" | |
| self.show_header_details = not self.show_header_details | |
| # Update button icon | |
| toggle_button.props(f'icon={"visibility_off" if self.show_header_details else "visibility"}') | |
| # Clear and re-render the table | |
| table_container.clear() | |
| with table_container: | |
| self._render_table() | |
| def _render_table(self): | |
| """Render the rubric table with sticky headers.""" | |
| # Table with full width, no horizontal scroll needed | |
| with ui.element('table').classes('w-full border-collapse text-sm table-fixed'): | |
| self._render_table_header() | |
| self._render_table_body() | |
| def _clean_html(self, text: str) -> str: | |
| """Remove HTML tags from text.""" | |
| if not text: | |
| return "" | |
| # Remove HTML tags | |
| clean = re.sub(r'<[^>]+>', '', str(text)) | |
| # Replace with space | |
| clean = clean.replace(' ', ' ') | |
| return clean.strip() | |
| def _parse_html_to_rich_text(self, html_text: str): | |
| """Parse HTML text and return rich text or plain string.""" | |
| if not html_text or '<' not in html_text: | |
| return html_text | |
| parser = HTMLToRichText() | |
| parser.feed(html_text) | |
| return parser.get_rich_text() | |
| async def export_to_excel(self): | |
| """Export the rubric data to an Excel file with split columns and styling.""" | |
| try: | |
| # Create a new workbook | |
| wb = Workbook() | |
| ws = wb.active | |
| ws.title = "Rubric" | |
| # Get data | |
| levels = self._get_scoring_levels() | |
| groups = self._get_prompt_groups() | |
| main_header = self._get_main_header() | |
| # Define styles | |
| header_font = Font(bold=True, size=11) | |
| header_fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid") | |
| scoring_header_fill = PatternFill(start_color="E6E6FA", end_color="E6E6FA", fill_type="solid") | |
| # Color fills for split sections | |
| blue_fill = PatternFill(start_color="EFF6FF", end_color="EFF6FF", fill_type="solid") # Blue tint | |
| green_fill = PatternFill(start_color="F0FDF4", end_color="F0FDF4", fill_type="solid") # Green tint | |
| slate_fill = PatternFill(start_color="F8FAFC", end_color="F8FAFC", fill_type="solid") # Slate tint | |
| border = Border( | |
| left=Side(style='thin'), | |
| right=Side(style='thin'), | |
| top=Side(style='thin'), | |
| bottom=Side(style='thin') | |
| ) | |
| wrap_alignment = Alignment(wrap_text=True, vertical='top') | |
| center_alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) | |
| # Row 1: Main headers (spanning 2 rows for static columns) | |
| row = 1 | |
| # Prompt Group header (spans 2 rows) | |
| ws.cell(row, 1, "Prompt Group").font = header_font | |
| ws.cell(row, 1).fill = header_fill | |
| ws.cell(row, 1).border = border | |
| ws.cell(row, 1).alignment = center_alignment | |
| ws.merge_cells(start_row=row, start_column=1, end_row=row+1, end_column=1) | |
| # Prompts and Responses header (spans 2 rows) | |
| ws.cell(row, 2, "Prompts and Hypothetical Complete Response").font = header_font | |
| ws.cell(row, 2).fill = header_fill | |
| ws.cell(row, 2).border = border | |
| ws.cell(row, 2).alignment = center_alignment | |
| ws.merge_cells(start_row=row, start_column=2, end_row=row+1, end_column=2) | |
| # Alignment header (spans across row 1, split in row 2) | |
| ws.cell(row, 3, "Alignment and Prompt Group Performance Expectation").font = header_font | |
| ws.cell(row, 3).fill = header_fill | |
| ws.cell(row, 3).border = border | |
| ws.cell(row, 3).alignment = center_alignment | |
| # Purpose header (spans 2 rows) | |
| ws.cell(row, 4, "Purpose and Notes").font = header_font | |
| ws.cell(row, 4).fill = header_fill | |
| ws.cell(row, 4).border = border | |
| ws.cell(row, 4).alignment = center_alignment | |
| ws.merge_cells(start_row=row, start_column=4, end_row=row+1, end_column=4) | |
| # Scoring header (spans all scoring columns) | |
| scoring_start_col = 5 | |
| scoring_end_col = 5 + len(levels) - 1 | |
| ws.cell(row, scoring_start_col, main_header).font = header_font | |
| ws.cell(row, scoring_start_col).fill = scoring_header_fill | |
| ws.cell(row, scoring_start_col).border = border | |
| ws.cell(row, scoring_start_col).alignment = center_alignment | |
| ws.merge_cells(start_row=row, start_column=scoring_start_col, end_row=row, end_column=scoring_end_col) | |
| # Row 2: Sub-headers | |
| row = 2 | |
| # Alignment sub-headers | |
| ws.cell(row, 3, "Standards (top) / Performance Expectation (bottom)").font = Font(bold=True, size=9) | |
| ws.cell(row, 3).fill = header_fill | |
| ws.cell(row, 3).border = border | |
| ws.cell(row, 3).alignment = center_alignment | |
| # Scoring level sub-headers | |
| col = scoring_start_col | |
| for level in levels: | |
| level_text = f"{level['title']}\n{level.get('generic_descriptor', '')}" | |
| ws.cell(row, col, level_text).font = header_font | |
| ws.cell(row, col).fill = scoring_header_fill | |
| ws.cell(row, col).border = border | |
| ws.cell(row, col).alignment = wrap_alignment | |
| col += 1 | |
| # Set column widths | |
| ws.column_dimensions['A'].width = 20 | |
| ws.column_dimensions['B'].width = 45 | |
| ws.column_dimensions['C'].width = 45 | |
| ws.column_dimensions['D'].width = 20 | |
| for i in range(len(levels)): | |
| ws.column_dimensions[get_column_letter(5 + i)].width = 35 | |
| # Add data rows (each group gets 2 rows for split sections) | |
| row = 3 | |
| for group in groups: | |
| # Each group uses 2 rows | |
| top_row = row | |
| bottom_row = row + 1 | |
| # Prompt Group title (merged across both rows) | |
| ws.cell(top_row, 1, group.get('title', '')) | |
| ws.cell(top_row, 1).border = border | |
| ws.cell(top_row, 1).alignment = wrap_alignment | |
| ws.merge_cells(start_row=top_row, start_column=1, end_row=bottom_row, end_column=1) | |
| # Prompts and Responses (merged across both rows) | |
| items_text = self._format_items_for_excel(group.get('items', [])) | |
| ws.cell(top_row, 2, items_text) | |
| ws.cell(top_row, 2).border = border | |
| ws.cell(top_row, 2).alignment = wrap_alignment | |
| ws.merge_cells(start_row=top_row, start_column=2, end_row=bottom_row, end_column=2) | |
| # Alignment - SPLIT into top (Standards) and bottom (Performance Expectation) | |
| # Top: Standards | |
| standards_text = self._format_alignment_standards_for_excel(group) | |
| ws.cell(top_row, 3, standards_text) | |
| ws.cell(top_row, 3).border = border | |
| ws.cell(top_row, 3).alignment = wrap_alignment | |
| ws.cell(top_row, 3).fill = slate_fill | |
| # Bottom: Performance Expectation (with rich text) | |
| alignment_text = group.get('alignment', '') | |
| if alignment_text: | |
| rich_text = self._parse_html_to_rich_text(alignment_text) | |
| ws.cell(bottom_row, 3, rich_text) | |
| else: | |
| ws.cell(bottom_row, 3, '—') | |
| ws.cell(bottom_row, 3).border = border | |
| ws.cell(bottom_row, 3).alignment = wrap_alignment | |
| # Purpose (merged across both rows) | |
| ws.cell(top_row, 4, group.get('purpose', '') or '—') | |
| ws.cell(top_row, 4).border = border | |
| ws.cell(top_row, 4).alignment = wrap_alignment | |
| ws.merge_cells(start_row=top_row, start_column=4, end_row=bottom_row, end_column=4) | |
| # Scoring cells - SPLIT into top (Criteria) and bottom (Sample Responses) | |
| group_scoring_data = group.get('group_scoring_data', []) | |
| col = 5 | |
| for level_info in levels: | |
| level_num = level_info.get('level', 0) | |
| entry = self._get_scoring_for_level(group_scoring_data, level_num) | |
| description = entry.get("description", "") | |
| sample_responses = entry.get("sample_responses", "") | |
| # Top: Criteria | |
| ws.cell(top_row, col, description or "Not yet defined") | |
| ws.cell(top_row, col).border = border | |
| ws.cell(top_row, col).alignment = wrap_alignment | |
| ws.cell(top_row, col).fill = blue_fill | |
| # Bottom: Sample Responses | |
| ws.cell(bottom_row, col, sample_responses or "No examples yet") | |
| ws.cell(bottom_row, col).border = border | |
| ws.cell(bottom_row, col).alignment = wrap_alignment | |
| ws.cell(bottom_row, col).fill = green_fill | |
| col += 1 | |
| row += 2 # Move to next group (skip 2 rows) | |
| # Adjust row heights for better readability | |
| for row_idx in range(3, ws.max_row + 1): | |
| ws.row_dimensions[row_idx].height = 60 | |
| # Save to bytes | |
| excel_buffer = io.BytesIO() | |
| wb.save(excel_buffer) | |
| excel_buffer.seek(0) | |
| # Create download link | |
| excel_data = excel_buffer.getvalue() | |
| b64_data = base64.b64encode(excel_data).decode() | |
| # Trigger download using JavaScript | |
| download_js = f''' | |
| const link = document.createElement('a'); | |
| link.href = 'data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,{b64_data}'; | |
| link.download = 'rubric_export.xlsx'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| ''' | |
| ui.run_javascript(download_js) | |
| ui.notify("Excel file downloaded successfully!", color='positive') | |
| except Exception as e: | |
| ui.notify(f"Export failed: {str(e)}", color='negative') | |
| def _format_items_for_excel(self, items: list) -> str: | |
| """Format items for Excel export.""" | |
| if not items: | |
| return "—" | |
| result = [] | |
| for i, item in enumerate(items): | |
| item_text = f"Item {item.get('id', i+1)}\n" | |
| prompt_text = item.get('prompt_text', '') | |
| if prompt_text: | |
| item_text += f"{prompt_text}\n" | |
| options = item.get('options', []) | |
| correct_answer = item.get('correct_answer_text', '') | |
| if options: | |
| item_text += "Options:\n" | |
| for option in options: | |
| is_correct = self._is_correct_option(option, correct_answer) | |
| marker = "✓ " if is_correct else " " | |
| item_text += f"{marker}{option}\n" | |
| elif correct_answer: | |
| item_text += f"Ideal Response: {correct_answer}\n" | |
| result.append(item_text) | |
| return "\n".join(result) | |
| def _format_alignment_standards_for_excel(self, group: dict): | |
| """Format alignment standards with colors for Excel export.""" | |
| alignment_data = group.get('alignment_data', []) | |
| if not alignment_data: | |
| return "—" | |
| # Build rich text with colored standards | |
| result_parts = [] | |
| for item in alignment_data: | |
| standard_type = item.get('type', 'dci') | |
| code = item.get('code', '') | |
| html_content = item.get('html_content', item.get('text', '')) | |
| # Get colors based on type (matching the UI) | |
| if standard_type == 'dci': | |
| color = 'FF9900' # Orange | |
| elif standard_type == 'sep': | |
| color = '4A86E8' # Blue | |
| elif standard_type == 'ccc': | |
| color = '6AA84F' # Green | |
| else: | |
| color = '000000' # Black | |
| # Add the code (bold and colored) | |
| font = InlineFont(b=True, color=Color(rgb=color)) | |
| result_parts.append(TextBlock(font, code)) | |
| result_parts.append(": ") | |
| # Parse the HTML content to preserve user formatting (bold, etc.) | |
| # BUT apply the standard-type color to all parts | |
| if html_content and '<' in html_content: | |
| # Parse HTML to get formatted segments | |
| parser = HTMLToRichText() | |
| parser.feed(html_content) | |
| parsed_result = parser.get_rich_text() | |
| # If we got CellRichText back, we need to apply our color to each part | |
| if isinstance(parsed_result, CellRichText): | |
| for part in parsed_result: | |
| if isinstance(part, TextBlock): | |
| # Preserve user's bold/italic/underline but override color | |
| font_kwargs = {} | |
| if hasattr(part.font, 'b') and part.font.b: | |
| font_kwargs['b'] = True | |
| if hasattr(part.font, 'i') and part.font.i: | |
| font_kwargs['i'] = True | |
| if hasattr(part.font, 'u') and part.font.u: | |
| font_kwargs['u'] = part.font.u | |
| # Always apply the standard-type color | |
| font_kwargs['color'] = Color(rgb=color) | |
| new_font = InlineFont(**font_kwargs) | |
| result_parts.append(TextBlock(new_font, part.text)) | |
| else: | |
| # Plain text - apply standard color | |
| content_font = InlineFont(color=Color(rgb=color)) | |
| result_parts.append(TextBlock(content_font, str(part))) | |
| else: | |
| # Plain string result | |
| content_font = InlineFont(color=Color(rgb=color)) | |
| result_parts.append(TextBlock(content_font, str(parsed_result))) | |
| else: | |
| # No HTML formatting, just plain text | |
| clean_content = html_content.strip() if html_content else '' | |
| if clean_content: | |
| content_font = InlineFont(color=Color(rgb=color)) | |
| result_parts.append(TextBlock(content_font, clean_content)) | |
| result_parts.append("\n") | |
| # Return as CellRichText | |
| if result_parts: | |
| # Remove last newline | |
| if result_parts[-1] == "\n": | |
| result_parts.pop() | |
| return CellRichText(result_parts) | |
| return "—" | |
| def _format_alignment_for_excel(self, group: dict) -> str: | |
| """Format alignment data for Excel export (legacy, not used with split columns).""" | |
| result = [] | |
| # Standards | |
| alignment_data = group.get('alignment_data', []) | |
| if alignment_data: | |
| result.append("Standards:") | |
| for item in alignment_data: | |
| code = item.get('code', '') | |
| html_content = item.get('html_content', item.get('text', '')) | |
| clean_content = self._clean_html(html_content) | |
| result.append(f" {code}: {clean_content}") | |
| result.append("") | |
| # Performance Expectation | |
| alignment_text = group.get('alignment', '') | |
| if alignment_text: | |
| clean_text = self._clean_html(alignment_text) | |
| result.append("Performance Expectation:") | |
| result.append(clean_text) | |
| return "\n".join(result) if result else "—" | |
| def show(self): | |
| """Display the modal dialog.""" | |
| # Create dialog with backdrop click to close | |
| with ui.dialog() as self.dialog: | |
| self.dialog.props('maximized') | |
| self.dialog.on('click', lambda e: self.dialog.close() if e.args.get('target') == e.args.get('currentTarget') else None) | |
| # Main modal container - 90vw x 90vh, centered | |
| with ui.element('div').classes( | |
| 'bg-white dark:bg-slate-900 rounded-lg shadow-2xl flex flex-col relative' | |
| ).style('width: 90vw; height: 90vh; max-width: 90vw; max-height: 90vh;'): | |
| # Header bar - fixed at top | |
| with ui.element('div').classes( | |
| 'flex justify-between items-center px-6 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0' | |
| ): | |
| ui.label('Rubric Preview').classes('text-xl font-bold text-slate-800 dark:text-slate-200') | |
| # Header buttons | |
| with ui.element('div').classes('flex items-center gap-3'): | |
| # Toggle button for scoring details | |
| toggle_btn = ui.button('Show Scoring Details', icon='visibility').props('outline').classes( | |
| 'text-slate-600' | |
| ) | |
| ui.button('Export to Excel', icon='download', on_click=self.export_to_excel).props('outline').classes( | |
| 'text-slate-600' | |
| ) | |
| # Close X button - always visible in header | |
| ui.button(icon='close', on_click=self.dialog.close).props('flat round dense').classes( | |
| 'text-slate-500 hover:text-slate-700 hover:bg-slate-100' | |
| ) | |
| # Scrollable content area - takes remaining space, no top padding so table header is flush | |
| with ui.element('div').classes('flex-1 overflow-y-auto overflow-x-auto px-6 pb-6') as table_container: | |
| if not self.rubric_data or not self._get_scoring_levels(): | |
| self._render_empty_state() | |
| else: | |
| self._render_table() | |
| # Wire up the toggle button after table is rendered | |
| toggle_btn.on('click', lambda: self._toggle_header_details(toggle_btn, table_container)) | |
| self.dialog.open() | |