feat: add support for TABLE_AUTO question type with table input rendering and auto-grading
This commit is contained in:
@@ -68,6 +68,17 @@ class AnswerInline(StackedInline):
|
||||
return ""
|
||||
if obj.question.question_type == 'TEXT_AUTO':
|
||||
return obj.text_answer or "Нет ответа"
|
||||
elif obj.question.question_type == 'TABLE_AUTO':
|
||||
import json
|
||||
try:
|
||||
data = json.loads(obj.text_answer)
|
||||
html = '<table style="border-collapse: collapse; margin-top: 4px;">'
|
||||
for row in data:
|
||||
html += f'<tr><td style="border: 1px solid #e5e7eb; padding: 4px 8px;">{row[0]}</td><td style="border: 1px solid #e5e7eb; padding: 4px 8px;">{row[1]}</td></tr>'
|
||||
html += '</table>'
|
||||
return format_html(html)
|
||||
except Exception:
|
||||
return obj.text_answer or "Нет ответа"
|
||||
else:
|
||||
if obj.file_answer:
|
||||
return format_html('<a href="{}" target="_blank">Скачать файл</a>', obj.file_answer.url)
|
||||
|
||||
@@ -14,6 +14,16 @@ class AnswersForm(forms.Form):
|
||||
'placeholder': 'Введите ваш ответ здесь...'
|
||||
})
|
||||
)
|
||||
elif q.question_type == 'TABLE_AUTO':
|
||||
rows = getattr(q, 'table_rows', 2) or 2
|
||||
for i in range(1, rows + 1):
|
||||
for j in range(1, 3):
|
||||
self.fields[f'answer_{q.id}_r{i}_c{j}'] = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'w-full px-2 py-1 border rounded focus:outline-none focus:ring-1 focus:ring-blue-500',
|
||||
})
|
||||
)
|
||||
else:
|
||||
self.fields[f'file_{q.id}'] = forms.FileField(
|
||||
label='Прикрепить файл:',
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.1 on 2026-09-19 09:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('lms', '0009_remove_assignment_deadline'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='table_rows',
|
||||
field=models.PositiveIntegerField(blank=True, default=2, help_text='Укажите количество строк. Столбцов всегда 2.', null=True, verbose_name='Количество строк в таблице'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='question',
|
||||
name='correct_answer',
|
||||
field=models.CharField(blank=True, help_text='Оставьте пустым для ручной проверки. Для таблицы вводите ответы в формате "Ответ1|Ответ2;Ответ3|Ответ4" (строки через точку с запятой, столбцы через прямую черту).', max_length=255, null=True, verbose_name='Правильный ответ (для автопроверки)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='question',
|
||||
name='question_type',
|
||||
field=models.CharField(choices=[('TEXT_AUTO', 'Краткий ответ (автопроверка)'), ('FILE_MANUAL', 'Файл/Развернутый ответ (ручная проверка)'), ('TABLE_AUTO', 'Таблица (2 столбца, автопроверка)')], default='TEXT_AUTO', max_length=20, verbose_name='Тип ответа'),
|
||||
),
|
||||
]
|
||||
@@ -31,12 +31,14 @@ class Question(models.Model):
|
||||
TYPE_CHOICES = (
|
||||
('TEXT_AUTO', 'Краткий ответ (автопроверка)'),
|
||||
('FILE_MANUAL', 'Файл/Развернутый ответ (ручная проверка)'),
|
||||
('TABLE_AUTO', 'Таблица (2 столбца, автопроверка)'),
|
||||
)
|
||||
assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE, related_name='questions', verbose_name='Задание')
|
||||
text = RichTextUploadingField('Текст задачи')
|
||||
attached_file = models.FileField('Файл (опционально)', upload_to='questions/', blank=True, null=True, validators=[validate_file_extension])
|
||||
question_type = models.CharField('Тип ответа', max_length=20, choices=TYPE_CHOICES, default='TEXT_AUTO')
|
||||
correct_answer = models.CharField('Правильный ответ (для автопроверки)', max_length=255, blank=True, null=True, help_text='Оставьте пустым, если проверка ручная')
|
||||
table_rows = models.PositiveIntegerField('Количество строк в таблице', default=2, blank=True, null=True, help_text='Укажите количество строк. Столбцов всегда 2.')
|
||||
correct_answer = models.CharField('Правильный ответ (для автопроверки)', max_length=255, blank=True, null=True, help_text='Оставьте пустым для ручной проверки. Для таблицы вводите ответы в формате "Ответ1|Ответ2;Ответ3|Ответ4" (строки через точку с запятой, столбцы через прямую черту).')
|
||||
order = models.PositiveIntegerField('Порядок', default=0)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -2,19 +2,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
function toggleCorrectAnswer(row) {
|
||||
const typeSelect = row.querySelector('select[name$="-question_type"]');
|
||||
const correctAnswerInput = row.querySelector('[name$="-correct_answer"]');
|
||||
const tableRowsInput = row.querySelector('[name$="-table_rows"]');
|
||||
|
||||
if (typeSelect && correctAnswerInput) {
|
||||
let wrapper = row.querySelector('.field-correct_answer');
|
||||
if (!wrapper) {
|
||||
// Find a suitable parent div to hide. Unfold puts fields in divs, often with mb-4 or similar.
|
||||
// We go up to the closest field group container.
|
||||
wrapper = correctAnswerInput.closest('.form-row') || correctAnswerInput.parentElement.parentElement;
|
||||
if (typeSelect) {
|
||||
if (correctAnswerInput) {
|
||||
let wrapper = row.querySelector('.field-correct_answer');
|
||||
if (!wrapper) wrapper = correctAnswerInput.closest('.form-row') || correctAnswerInput.parentElement.parentElement;
|
||||
if (wrapper) {
|
||||
wrapper.style.display = typeSelect.value === 'FILE_MANUAL' ? 'none' : '';
|
||||
}
|
||||
}
|
||||
if (wrapper) {
|
||||
if (typeSelect.value === 'FILE_MANUAL') {
|
||||
wrapper.style.display = 'none';
|
||||
} else {
|
||||
wrapper.style.display = '';
|
||||
if (tableRowsInput) {
|
||||
let wrapper = row.querySelector('.field-table_rows');
|
||||
if (!wrapper) wrapper = tableRowsInput.closest('.form-row') || tableRowsInput.parentElement.parentElement;
|
||||
if (wrapper) {
|
||||
wrapper.style.display = typeSelect.value === 'TABLE_AUTO' ? '' : 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,32 @@
|
||||
<span class="material-icons text-[14px] align-middle">help_outline</span> Ожидает проверки
|
||||
{% endif %}
|
||||
</p>
|
||||
{% elif answer.question.question_type == 'TABLE_AUTO' %}
|
||||
<p class="text-gray-800 mb-2"><strong>Ваш ответ:</strong></p>
|
||||
{% load lms_tags %}
|
||||
{% with table_data=answer.text_answer|parse_json %}
|
||||
{% if table_data %}
|
||||
<table class="w-full border-collapse border border-gray-300 mb-4 text-sm bg-white">
|
||||
{% for row in table_data %}
|
||||
<tr>
|
||||
<td class="border border-gray-300 p-2">{{ row.0 }}</td>
|
||||
<td class="border border-gray-300 p-2">{{ row.1 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-gray-800">Нет ответа</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<p class="text-sm mt-1 {% if answer.is_correct %}text-green-600{% elif answer.is_correct == False %}text-red-600{% else %}text-gray-500{% endif %}">
|
||||
{% if answer.is_correct %}
|
||||
<span class="material-icons text-[14px] align-middle">check_circle</span> Верно
|
||||
{% elif answer.is_correct == False %}
|
||||
<span class="material-icons text-[14px] align-middle">cancel</span> Неверно
|
||||
{% else %}
|
||||
<span class="material-icons text-[14px] align-middle">help_outline</span> Ожидает проверки
|
||||
{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
{% if answer.file_answer %}
|
||||
<p class="text-gray-800"><strong>Прикрепленный файл:</strong> <a href="{{ answer.file_answer.url }}" class="text-blue-600 hover:underline">Скачать</a></p>
|
||||
@@ -72,7 +98,7 @@
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
{% for q, field in question_fields %}
|
||||
{% for q, field_type, field in question_fields %}
|
||||
<div class="p-5 border rounded-xl bg-gray-50 border-gray-200">
|
||||
<h4 class="font-semibold text-lg mb-2 text-gray-800">Задача {{ forloop.counter }}</h4>
|
||||
<div class="prose max-w-none text-gray-700 mb-4">
|
||||
@@ -88,10 +114,22 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-700 mb-2">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ field.errors.0 }}</p>
|
||||
{% if field_type == 'table' %}
|
||||
<p class="block text-sm font-medium text-gray-700 mb-2">Заполните таблицу:</p>
|
||||
<table class="w-full border-collapse border border-gray-300 mb-4">
|
||||
{% for row_fields in field %}
|
||||
<tr>
|
||||
<td class="border border-gray-300 p-2">{{ row_fields.0 }}</td>
|
||||
<td class="border border-gray-300 p-2">{{ row_fields.1 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-700 mb-2">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.errors %}
|
||||
<p class="mt-1 text-sm text-red-600">{{ field.errors.0 }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,3 +5,12 @@ register = template.Library()
|
||||
@register.simple_tag
|
||||
def get_submission(submissions_dict, assignment_id):
|
||||
return submissions_dict.get(assignment_id)
|
||||
|
||||
import json
|
||||
|
||||
@register.filter
|
||||
def parse_json(value):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
+40
-2
@@ -2,6 +2,7 @@ from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.utils.html import strip_tags
|
||||
from .models import Assignment, Submission, Answer
|
||||
import json
|
||||
from .forms import AnswersForm, StudentRegistrationForm
|
||||
from django.contrib.auth import login
|
||||
def register(request):
|
||||
@@ -84,6 +85,35 @@ def assignment_detail(request, pk):
|
||||
else:
|
||||
all_auto = False
|
||||
max_points += 1
|
||||
elif q.question_type == 'TABLE_AUTO':
|
||||
rows = getattr(q, 'table_rows', 2) or 2
|
||||
table_data = []
|
||||
for i in range(1, rows + 1):
|
||||
val1 = form.cleaned_data.get(f'answer_{q.id}_r{i}_c1', '')
|
||||
val2 = form.cleaned_data.get(f'answer_{q.id}_r{i}_c2', '')
|
||||
table_data.append([val1, val2])
|
||||
answer_obj.text_answer = json.dumps(table_data)
|
||||
if q.correct_answer:
|
||||
correct_rows = q.correct_answer.split(';')
|
||||
is_correct = True
|
||||
if len(correct_rows) != rows:
|
||||
is_correct = False
|
||||
else:
|
||||
for i, row in enumerate(correct_rows):
|
||||
cols = row.split('|')
|
||||
if len(cols) == 2:
|
||||
if cols[0].strip().lower() != table_data[i][0].strip().lower() or cols[1].strip().lower() != table_data[i][1].strip().lower():
|
||||
is_correct = False
|
||||
break
|
||||
else:
|
||||
is_correct = False
|
||||
break
|
||||
answer_obj.is_correct = is_correct
|
||||
answer_obj.points = 1 if is_correct else 0
|
||||
total_points += answer_obj.points
|
||||
else:
|
||||
all_auto = False
|
||||
max_points += 1
|
||||
else:
|
||||
all_auto = False
|
||||
val = form.cleaned_data.get(f'file_{q.id}')
|
||||
@@ -104,8 +134,16 @@ def assignment_detail(request, pk):
|
||||
question_fields = []
|
||||
if form:
|
||||
for q in questions:
|
||||
field = form[f'answer_{q.id}'] if q.question_type == 'TEXT_AUTO' else form[f'file_{q.id}']
|
||||
question_fields.append((q, field))
|
||||
if q.question_type == 'TEXT_AUTO':
|
||||
question_fields.append((q, 'text', form[f'answer_{q.id}']))
|
||||
elif q.question_type == 'TABLE_AUTO':
|
||||
rows = getattr(q, 'table_rows', 2) or 2
|
||||
table_fields = []
|
||||
for i in range(1, rows + 1):
|
||||
table_fields.append([form[f'answer_{q.id}_r{i}_c1'], form[f'answer_{q.id}_r{i}_c2']])
|
||||
question_fields.append((q, 'table', table_fields))
|
||||
else:
|
||||
question_fields.append((q, 'file', form[f'file_{q.id}']))
|
||||
|
||||
return render(request, 'lms/assignment_detail.html', {
|
||||
'assignment': assignment,
|
||||
|
||||
Reference in New Issue
Block a user