From 46319def76c7df02a037fccfd0c6edc2f139646e Mon Sep 17 00:00:00 2001 From: Dmitry Belan Date: Sat, 19 Sep 2026 12:19:02 +0300 Subject: [PATCH] feat: add support for TABLE_AUTO question type with table input rendering and auto-grading --- lmsege/lms/admin.py | 11 ++++ lmsege/lms/forms.py | 10 ++++ ..._alter_question_correct_answer_and_more.py | 28 ++++++++++ lmsege/lms/models.py | 4 +- lmsege/lms/static/js/admin_assignment.js | 24 +++++---- .../lms/templates/lms/assignment_detail.html | 48 ++++++++++++++++-- lmsege/lms/templatetags/lms_tags.py | 9 ++++ lmsege/lms/views.py | 42 ++++++++++++++- .../__pycache__/settings.cpython-314.pyc | Bin 3198 -> 3443 bytes 9 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 lmsege/lms/migrations/0010_question_table_rows_alter_question_correct_answer_and_more.py diff --git a/lmsege/lms/admin.py b/lmsege/lms/admin.py index d6023c4..1fb0b71 100644 --- a/lmsege/lms/admin.py +++ b/lmsege/lms/admin.py @@ -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 = '' + for row in data: + html += f'' + html += '
{row[0]}{row[1]}
' + return format_html(html) + except Exception: + return obj.text_answer or "Нет ответа" else: if obj.file_answer: return format_html('Скачать файл', obj.file_answer.url) diff --git a/lmsege/lms/forms.py b/lmsege/lms/forms.py index 97a68b5..bb358db 100644 --- a/lmsege/lms/forms.py +++ b/lmsege/lms/forms.py @@ -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='Прикрепить файл:', diff --git a/lmsege/lms/migrations/0010_question_table_rows_alter_question_correct_answer_and_more.py b/lmsege/lms/migrations/0010_question_table_rows_alter_question_correct_answer_and_more.py new file mode 100644 index 0000000..4be638d --- /dev/null +++ b/lmsege/lms/migrations/0010_question_table_rows_alter_question_correct_answer_and_more.py @@ -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='Тип ответа'), + ), + ] diff --git a/lmsege/lms/models.py b/lmsege/lms/models.py index 748d2eb..afddaf7 100644 --- a/lmsege/lms/models.py +++ b/lmsege/lms/models.py @@ -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: diff --git a/lmsege/lms/static/js/admin_assignment.js b/lmsege/lms/static/js/admin_assignment.js index 2583be5..ef9bb2e 100644 --- a/lmsege/lms/static/js/admin_assignment.js +++ b/lmsege/lms/static/js/admin_assignment.js @@ -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'; } } } diff --git a/lmsege/lms/templates/lms/assignment_detail.html b/lmsege/lms/templates/lms/assignment_detail.html index a8f5814..c4991aa 100644 --- a/lmsege/lms/templates/lms/assignment_detail.html +++ b/lmsege/lms/templates/lms/assignment_detail.html @@ -51,6 +51,32 @@ help_outline Ожидает проверки {% endif %}

+ {% elif answer.question.question_type == 'TABLE_AUTO' %} +

Ваш ответ:

+ {% load lms_tags %} + {% with table_data=answer.text_answer|parse_json %} + {% if table_data %} + + {% for row in table_data %} + + + + + {% endfor %} +
{{ row.0 }}{{ row.1 }}
+ {% else %} +

Нет ответа

+ {% endif %} + {% endwith %} +

+ {% if answer.is_correct %} + check_circle Верно + {% elif answer.is_correct == False %} + cancel Неверно + {% else %} + help_outline Ожидает проверки + {% endif %} +

{% else %} {% if answer.file_answer %}

Прикрепленный файл: Скачать

@@ -72,7 +98,7 @@
{% csrf_token %} - {% for q, field in question_fields %} + {% for q, field_type, field in question_fields %}

Задача {{ forloop.counter }}

@@ -88,10 +114,22 @@ {% endif %}
- - {{ field }} - {% if field.errors %} -

{{ field.errors.0 }}

+ {% if field_type == 'table' %} +

Заполните таблицу:

+ + {% for row_fields in field %} + + + + + {% endfor %} +
{{ row_fields.0 }}{{ row_fields.1 }}
+ {% else %} + + {{ field }} + {% if field.errors %} +

{{ field.errors.0 }}

+ {% endif %} {% endif %}
diff --git a/lmsege/lms/templatetags/lms_tags.py b/lmsege/lms/templatetags/lms_tags.py index c8c023d..ff485b1 100644 --- a/lmsege/lms/templatetags/lms_tags.py +++ b/lmsege/lms/templatetags/lms_tags.py @@ -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 diff --git a/lmsege/lms/views.py b/lmsege/lms/views.py index 90562ae..1e32a9d 100644 --- a/lmsege/lms/views.py +++ b/lmsege/lms/views.py @@ -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, diff --git a/lmsege/lmsege/__pycache__/settings.cpython-314.pyc b/lmsege/lmsege/__pycache__/settings.cpython-314.pyc index 8973d3649301ba90ea506cbece743cf898c3c7c2..958a2c3283717ecbcf34ff96a97e38df362c3c00 100644 GIT binary patch delta 364 zcmew-@mY#bn~#@^0SIPUuFK-*pU5Y{7_m{kfRR(*pRq((k0Hol@&ZO_M#IT_7#FLm zm#74(m#7A5l&A%X25AOq1!)KA1nCCp=`-symZ%3YDl=#rZBAkO$ZT+!x zF{N1l7JEs4eoj(i(Jhv=(wv-IY#FJU=@})NI*beqw^++FQ%W+bSPTseR5iILhqJAc zy~UB7otlzal3#R75XOp!7?xVJnUP(QiBV&-GG`iN;4L8^e|Jy6_#jso&mdRlkoeFb zpIgE}G5^pIR6!AEZ=jkG|DgEL03Uxxm-qn35RY2|a8YM}KQ~YJ;K`j_&RovG@BliE zq4?tDYg|j%d4!l6_&&)@Zsh)E(ZK&eRN?}I&!#1$_vh&*87y}=$v|qvqs2jEh+r zl^Ha(Hn%W+WM