rubric-ai / frontend /components /rubric_preview.py
itslikethisnow's picture
lots of updates
8d306f9
"""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 &nbsp; with space
clean = clean.replace('&nbsp;', ' ')
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()