feat: add support for TABLE_AUTO question type with table input rendering and auto-grading

This commit is contained in:
Dmitry Belan
2026-09-19 12:19:02 +03:00
parent 9ee1788692
commit 46319def76
9 changed files with 157 additions and 19 deletions
+11
View File
@@ -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)
+10
View File
@@ -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='Прикрепить файл:',
@@ -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='Тип ответа'),
),
]
+3 -1
View File
@@ -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:
+12 -10
View File
@@ -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) {
if (typeSelect) {
if (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 (!wrapper) wrapper = correctAnswerInput.closest('.form-row') || correctAnswerInput.parentElement.parentElement;
if (wrapper) {
if (typeSelect.value === 'FILE_MANUAL') {
wrapper.style.display = 'none';
} else {
wrapper.style.display = '';
wrapper.style.display = typeSelect.value === 'FILE_MANUAL' ? 'none' : '';
}
}
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,11 +114,23 @@
{% endif %}
<div class="bg-white p-4 rounded-lg border border-gray-200">
{% 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>
{% endfor %}
+9
View File
@@ -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
View File
@@ -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,
Binary file not shown.