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 "" return ""
if obj.question.question_type == 'TEXT_AUTO': if obj.question.question_type == 'TEXT_AUTO':
return obj.text_answer or "Нет ответа" 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: else:
if obj.file_answer: if obj.file_answer:
return format_html('<a href="{}" target="_blank">Скачать файл</a>', obj.file_answer.url) return format_html('<a href="{}" target="_blank">Скачать файл</a>', obj.file_answer.url)
+10
View File
@@ -14,6 +14,16 @@ class AnswersForm(forms.Form):
'placeholder': 'Введите ваш ответ здесь...' '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: else:
self.fields[f'file_{q.id}'] = forms.FileField( self.fields[f'file_{q.id}'] = forms.FileField(
label='Прикрепить файл:', 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 = ( TYPE_CHOICES = (
('TEXT_AUTO', 'Краткий ответ (автопроверка)'), ('TEXT_AUTO', 'Краткий ответ (автопроверка)'),
('FILE_MANUAL', 'Файл/Развернутый ответ (ручная проверка)'), ('FILE_MANUAL', 'Файл/Развернутый ответ (ручная проверка)'),
('TABLE_AUTO', 'Таблица (2 столбца, автопроверка)'),
) )
assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE, related_name='questions', verbose_name='Задание') assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE, related_name='questions', verbose_name='Задание')
text = RichTextUploadingField('Текст задачи') text = RichTextUploadingField('Текст задачи')
attached_file = models.FileField('Файл (опционально)', upload_to='questions/', blank=True, null=True, validators=[validate_file_extension]) 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') 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) order = models.PositiveIntegerField('Порядок', default=0)
class Meta: class Meta:
+13 -11
View File
@@ -2,19 +2,21 @@ document.addEventListener('DOMContentLoaded', function() {
function toggleCorrectAnswer(row) { function toggleCorrectAnswer(row) {
const typeSelect = row.querySelector('select[name$="-question_type"]'); const typeSelect = row.querySelector('select[name$="-question_type"]');
const correctAnswerInput = row.querySelector('[name$="-correct_answer"]'); const correctAnswerInput = row.querySelector('[name$="-correct_answer"]');
const tableRowsInput = row.querySelector('[name$="-table_rows"]');
if (typeSelect && correctAnswerInput) { if (typeSelect) {
let wrapper = row.querySelector('.field-correct_answer'); if (correctAnswerInput) {
if (!wrapper) { let wrapper = row.querySelector('.field-correct_answer');
// Find a suitable parent div to hide. Unfold puts fields in divs, often with mb-4 or similar. if (!wrapper) wrapper = correctAnswerInput.closest('.form-row') || correctAnswerInput.parentElement.parentElement;
// We go up to the closest field group container. if (wrapper) {
wrapper = correctAnswerInput.closest('.form-row') || correctAnswerInput.parentElement.parentElement; wrapper.style.display = typeSelect.value === 'FILE_MANUAL' ? 'none' : '';
}
} }
if (wrapper) { if (tableRowsInput) {
if (typeSelect.value === 'FILE_MANUAL') { let wrapper = row.querySelector('.field-table_rows');
wrapper.style.display = 'none'; if (!wrapper) wrapper = tableRowsInput.closest('.form-row') || tableRowsInput.parentElement.parentElement;
} else { if (wrapper) {
wrapper.style.display = ''; wrapper.style.display = typeSelect.value === 'TABLE_AUTO' ? '' : 'none';
} }
} }
} }
@@ -51,6 +51,32 @@
<span class="material-icons text-[14px] align-middle">help_outline</span> Ожидает проверки <span class="material-icons text-[14px] align-middle">help_outline</span> Ожидает проверки
{% endif %} {% endif %}
</p> </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 %} {% else %}
{% if answer.file_answer %} {% 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> <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"> <form method="post" enctype="multipart/form-data" class="space-y-6">
{% csrf_token %} {% 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"> <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> <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"> <div class="prose max-w-none text-gray-700 mb-4">
@@ -88,10 +114,22 @@
{% endif %} {% endif %}
<div class="bg-white p-4 rounded-lg border border-gray-200"> <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> {% if field_type == 'table' %}
{{ field }} <p class="block text-sm font-medium text-gray-700 mb-2">Заполните таблицу:</p>
{% if field.errors %} <table class="w-full border-collapse border border-gray-300 mb-4">
<p class="mt-1 text-sm text-red-600">{{ field.errors.0 }}</p> {% 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 %} {% endif %}
</div> </div>
</div> </div>
+9
View File
@@ -5,3 +5,12 @@ register = template.Library()
@register.simple_tag @register.simple_tag
def get_submission(submissions_dict, assignment_id): def get_submission(submissions_dict, assignment_id):
return submissions_dict.get(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.contrib.auth.decorators import login_required, user_passes_test
from django.utils.html import strip_tags from django.utils.html import strip_tags
from .models import Assignment, Submission, Answer from .models import Assignment, Submission, Answer
import json
from .forms import AnswersForm, StudentRegistrationForm from .forms import AnswersForm, StudentRegistrationForm
from django.contrib.auth import login from django.contrib.auth import login
def register(request): def register(request):
@@ -84,6 +85,35 @@ def assignment_detail(request, pk):
else: else:
all_auto = False all_auto = False
max_points += 1 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: else:
all_auto = False all_auto = False
val = form.cleaned_data.get(f'file_{q.id}') val = form.cleaned_data.get(f'file_{q.id}')
@@ -104,8 +134,16 @@ def assignment_detail(request, pk):
question_fields = [] question_fields = []
if form: if form:
for q in questions: for q in questions:
field = form[f'answer_{q.id}'] if q.question_type == 'TEXT_AUTO' else form[f'file_{q.id}'] if q.question_type == 'TEXT_AUTO':
question_fields.append((q, field)) 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', { return render(request, 'lms/assignment_detail.html', {
'assignment': assignment, 'assignment': assignment,
Binary file not shown.