Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 57 additions & 100 deletions backend/openedx_ai_extensions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django import forms
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.utils.html import format_html
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe

from openedx_ai_extensions.models import PromptTemplate
Expand All @@ -30,7 +30,7 @@ def get_fieldsets(self, request, obj=None):
if obj and obj.pk:
# Editing existing - show UUID example
identification_description = (
f'Slug is human-readable, ID is the stable UUID reference. <br/>'
f'Slug is human-readable, ID is the stable UUID reference.<br/>'
f'Use in profile: <code>"prompt_template": "{obj.pk}"</code> or '
f'<code>"prompt_template": "{obj.slug}"</code>'
)
Expand All @@ -43,15 +43,15 @@ def get_fieldsets(self, request, obj=None):
return (
('Identification', {
'fields': ('slug', 'id'),
'description': format_html(identification_description)
'description': format_html(identification_description),
}),
('Prompt Content', {
'fields': ('body',),
'description': 'The prompt template text - edit in the big textbox below.'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some comments here we don't to remove from now, please keep this one as before

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

'description': 'The prompt template text - edit in the big textbox below.',
}),
('Timestamps', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
'classes': ('collapse',),
}),
)

Expand All @@ -63,7 +63,7 @@ def get_form(self, request, obj=None, change=False, **kwargs):
'rows': 25,
'cols': 120,
'class': 'vLargeTextField',
'style': 'font-family: monospace; font-size: 14px;'
'style': 'font-family: monospace; font-size: 14px;',
})
return form

Expand All @@ -73,6 +73,7 @@ def body_preview(self, obj):
preview = obj.body[:80].replace('\n', ' ')
return preview + ('...' if len(obj.body) > 80 else '')
return '-'

body_preview.short_description = 'Prompt Preview'


Expand All @@ -89,7 +90,7 @@ class Meta:
'rows': 20,
'cols': 80,
'class': 'vLargeTextField',
'style': 'font-family: monospace;'
'style': 'font-family: monospace;',
}),
}

Expand Down Expand Up @@ -120,11 +121,9 @@ def clean_content_patch(self):
# Validate JSON5 syntax
try:
parse_json5_string(content_patch_raw)
except Exception as e:
# json5 library may not expose JSON5DecodeError in all versions
raise ValidationError(f'Invalid JSON5 syntax: {e}') from e
except Exception as exc:
raise ValidationError(f'Invalid JSON5 syntax: {exc}') from exc

# Return the raw string (we store it as text, parse at runtime)
return content_patch_raw


Expand All @@ -142,7 +141,7 @@ class AIWorkflowProfileAdmin(admin.ModelAdmin):

fieldsets = (
('Basic Information', {
'fields': ('slug', 'description')
'fields': ('slug', 'description'),
}),
('Profile Template Configuration', {
'fields': ('base_filepath', 'base_template_preview', 'content_patch'),
Expand All @@ -168,12 +167,10 @@ def is_valid(self, obj):
"""Show validation status with icon."""
is_valid, errors = obj.validate()
if is_valid:
return format_html(
'<span style="color: green;">✓ Valid</span>'
)
return format_html('<span class="ai-admin-preview--success">✓ Valid</span>')
return format_html(
'<span style="color: red;">✗ {} errors</span>',
len(errors)
'<span class="ai-admin-preview--error">✗ {} errors</span>',
len(errors),
)
is_valid.short_description = 'Status'

Expand All @@ -189,73 +186,40 @@ def base_template_preview(self, obj):

if not is_safe_template_path(obj.base_filepath):
return format_html(
'<div style="background: #fee; padding: 10px; border-radius: 4px; color: #c00;">'
'<div class="ai-admin-preview ai-admin-preview--error">'
'<strong>Error:</strong> Invalid or unsafe template path'
'</div>'
)

try:
# Find and read the template file as-is
template_dirs = get_template_directories()
file_content = None

for base_dir in template_dirs:
full_path = base_dir / obj.base_filepath
if full_path.exists():
with open(full_path, 'r', encoding='utf-8') as f:
file_content = f.read()
break

if file_content is None:
return format_html(
'<div style="background: #fee; padding: 10px; '
'border-radius: 4px; color: #c00;">'
'<strong>Error:</strong> Template file not found'
'</div>'
)

# Generate unique ID for this preview
preview_id = f"base-template-{obj.pk or 'new'}"
file_content = None
for base_dir in get_template_directories():
full_path = base_dir / obj.base_filepath
if full_path.exists():
file_content = full_path.read_text(encoding='utf-8')
break

if file_content is None:
return format_html(
'<div>'
'<a href="#" onclick="'
'var el = document.getElementById(\'{id}\'); '
'var link = this; '
'if (el.style.display === \'none\') {{ '
' el.style.display = \'block\'; '
' link.textContent = '
'\'▼ Hide Base Template ({filepath})\'; '
'}} else {{ '
' el.style.display = \'none\'; '
' link.textContent = '
'\'▶ Show Base Template ({filepath})\'; '
'}} '
'return false;" '
'style="text-decoration: none; color: #447e9b; font-weight: bold;">'
'▶ Show Base Template ({filepath})'
'</a>'
'<div id="{id}" style="display: none; '
'background: #f0f8ff; padding: 10px; '
'border-radius: 4px; border: 1px solid #ddd; '
'margin-top: 10px;">'
'<pre style="margin: 0; font-family: monospace; '
'font-size: 12px; max-height: 400px; '
'overflow-y: auto;">{content}</pre>'
'<div class="ai-admin-preview ai-admin-preview--error">'
'<strong>Error:</strong> Template file not found'
'</div>'
'</div>',
id=preview_id,
filepath=obj.base_filepath,
content=file_content
)
except Exception as e: # pylint: disable=broad-exception-caught
return format_html(
'<div style="background: #fee; padding: 10px; '
'border-radius: 4px; color: #c00;">'
'<strong>Error loading template:</strong> {}'
'</div>',
str(e)
)

preview_id = f'base-template-{obj.pk or "new"}'

return format_html(
'<a href="#" class="ai-admin-toggle" '
'onclick="var el=document.getElementById(\'{id}\');'
'el.style.display = el.style.display === \'none\' ? \'block\' : \'none\';'
'return false;">'
'▶ Toggle Base Template ({path})</a>'
'<div id="{id}" class="ai-admin-preview" style="display:none;">'
'<pre>{content}</pre>'
'</div>',
id=preview_id,
path=obj.base_filepath,
content=escape(file_content),
)
base_template_preview.short_description = 'Base Template (Read-Only)'

def effective_config_preview(self, obj):
Expand All @@ -264,23 +228,20 @@ def effective_config_preview(self, obj):
return '-'

try:
config = obj.config
formatted_json = json.dumps(config, indent=2, sort_keys=True)

formatted = json.dumps(obj.config, indent=2, sort_keys=True)
return format_html(
'<div style="background: #f8f8f8; padding: 10px; border-radius: 4px;">'
'<strong>Effective Configuration (Base Template + Overrides):</strong><br>'
'<pre style="margin-top: 10px; font-family: monospace; font-size: 12px;">{}</pre>'
'<div class="ai-admin-preview">'
'<strong>Effective Configuration:</strong>'
'<pre>{}</pre>'
'</div>',
formatted_json
formatted,
)
except Exception as e: # pylint: disable=broad-exception-caught
except Exception as exc: # pylint: disable=broad-exception-caught
return format_html(
'<div style="background: #fee; padding: 10px; '
'border-radius: 4px; color: #c00;">'
'<strong>Error loading configuration:</strong> {}'
'<div class="ai-admin-preview ai-admin-preview--error">'
'<strong>Error:</strong> {}'
'</div>',
str(e)
exc,
)
effective_config_preview.short_description = 'Effective Configuration'

Expand All @@ -293,25 +254,25 @@ def validation_status(self, obj):

if is_valid:
return format_html(
'<div style="background: #efe; padding: 10px; border-radius: 4px; color: #060;">'
'<strong>✓ Configuration is valid</strong>'
'<div class="ai-admin-preview ai-admin-preview--success">'
'✓ Configuration is valid'
'</div>'
)

error_list = '<br>'.join(f'• {error}' for error in errors)
error_list = '<br>'.join(f'• {escape(e)}' for e in errors)
return format_html(
'<div style="background: #fee; padding: 10px; border-radius: 4px; color: #c00;">'
'<strong>Validation Errors:</strong><br>{}'
'<div class="ai-admin-preview ai-admin-preview--error">'
'<strong>Validation errors:</strong><br>{}'
'</div>',
mark_safe(error_list)
mark_safe(error_list),
)
validation_status.short_description = 'Validation Status'

class Media:
"""Admin media assets."""

css = {
'all': ('admin/css/forms.css',)
'all': ('openedx_ai_extensions/admin.css',),
}


Expand All @@ -321,11 +282,7 @@ class AIWorkflowSessionAdmin(admin.ModelAdmin):
Admin interface for managing AI Workflow Sessions.
"""

list_display = (
"user",
"course_id",
"location_id",
)
list_display = ("user", "course_id", "location_id")
search_fields = ("user__username", "course_id", "location_id")
readonly_fields = ("local_submission_id", "remote_response_id", "metadata")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* Base preview container */
.ai-admin-preview {
background: var(--body-bg);
color: var(--body-fg);
border: 1px solid var(--hairline-color);
padding: 10px;
border-radius: 4px;
margin-top: 8px;
}

/* Code blocks */
.ai-admin-preview pre {
background: transparent;
color: inherit;
font-family: monospace;
font-size: 12px;
margin: 0;
max-height: 400px;
overflow-y: auto;
}

/* Toggle link */
.ai-admin-toggle {
color: var(--primary);
text-decoration: none;
font-weight: bold;
}

.ai-admin-toggle:hover {
text-decoration: underline;
}

/* Status blocks */
.ai-admin-preview--success {
background: var(--message-success-bg);
color: var(--message-success-fg);
}

.ai-admin-preview--error {
background: var(--message-error-bg);
color: var(--message-error-fg);
}