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 ""
|
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)
|
||||||
|
|||||||
@@ -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='Прикрепить файл:',
|
||||||
|
|||||||
+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 = (
|
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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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.
Reference in New Issue
Block a user