f
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
DEBUG=True
|
||||||
|
SECRET_KEY=django-insecure-ege-lms-secret-key-12345
|
||||||
|
ALLOWED_HOSTS=*
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE 1
|
||||||
|
ENV PYTHONUNBUFFERED 1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y gcc libffi-dev \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . /app/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "lmsege.wsgi:application"]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
command: >
|
||||||
|
sh -c "cd lmsege &&
|
||||||
|
python manage.py makemigrations users lms &&
|
||||||
|
python manage.py migrate &&
|
||||||
|
python manage.py collectstatic --noinput &&
|
||||||
|
gunicorn --bind 0.0.0.0:8000 lmsege.wsgi:application"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- db-data:/app/lmsege/db_data
|
||||||
|
- media-data:/app/lmsege/media
|
||||||
|
- static-data:/app/lmsege/staticfiles
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
media-data:
|
||||||
|
static-data:
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# init
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from unfold.admin import ModelAdmin, StackedInline
|
||||||
|
from django.db import models
|
||||||
|
from django.forms import widgets
|
||||||
|
from .models import Subject, Assignment, Submission, Question, Answer
|
||||||
|
|
||||||
|
@admin.register(Subject)
|
||||||
|
class SubjectAdmin(ModelAdmin):
|
||||||
|
list_display = ('name',)
|
||||||
|
list_display_links = ('name',)
|
||||||
|
|
||||||
|
class QuestionInline(StackedInline):
|
||||||
|
model = Question
|
||||||
|
extra = 1
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
js = ('js/admin_assignment.js',)
|
||||||
|
|
||||||
|
@admin.register(Assignment)
|
||||||
|
class AssignmentAdmin(ModelAdmin):
|
||||||
|
list_display = ('title', 'subject', 'created_at')
|
||||||
|
list_display_links = ('title',)
|
||||||
|
list_filter = ('subject',)
|
||||||
|
search_fields = ('title', 'description')
|
||||||
|
exclude = ('created_by',)
|
||||||
|
inlines = [QuestionInline]
|
||||||
|
actions = ['delete_selected_assignments']
|
||||||
|
|
||||||
|
@admin.action(description="Удалить выбранные задания")
|
||||||
|
def delete_selected_assignments(self, request, queryset):
|
||||||
|
deleted_count = 0
|
||||||
|
for obj in queryset:
|
||||||
|
if self.has_delete_permission(request, obj):
|
||||||
|
obj.delete()
|
||||||
|
deleted_count += 1
|
||||||
|
self.message_user(request, f"Успешно удалено {deleted_count} заданий.")
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
if request.user.is_superuser or request.user.role == 'ADMIN':
|
||||||
|
return qs
|
||||||
|
return qs.filter(created_by=request.user)
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
if getattr(obj, 'created_by_id', None) is None:
|
||||||
|
obj.created_by = request.user
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
if request.user.is_superuser or request.user.role == 'ADMIN':
|
||||||
|
return True
|
||||||
|
if request.user.role == 'TEACHER':
|
||||||
|
if obj is not None and obj.created_by != request.user:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
class AnswerInline(StackedInline):
|
||||||
|
model = Answer
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ('question', 'get_student_answer')
|
||||||
|
fields = ('question', 'get_student_answer', 'is_correct', 'points', 'comment')
|
||||||
|
|
||||||
|
def get_student_answer(self, obj):
|
||||||
|
if not obj or not obj.pk:
|
||||||
|
return ""
|
||||||
|
if obj.question.question_type == 'TEXT_AUTO':
|
||||||
|
return obj.text_answer or "Нет ответа"
|
||||||
|
else:
|
||||||
|
if obj.file_answer:
|
||||||
|
return format_html('<a href="{}" target="_blank">Скачать файл</a>', obj.file_answer.url)
|
||||||
|
return "Файл не прикреплен"
|
||||||
|
get_student_answer.short_description = 'Ответ ученика'
|
||||||
|
|
||||||
|
def has_add_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@admin.register(Submission)
|
||||||
|
class SubmissionAdmin(ModelAdmin):
|
||||||
|
change_list_template = "admin/lms/submission/change_list.html"
|
||||||
|
list_display = ('assignment', 'student', 'status', 'grade', 'submitted_at')
|
||||||
|
list_filter = ('status', 'assignment__subject', 'student')
|
||||||
|
search_fields = ('student__username', 'assignment__title')
|
||||||
|
inlines = [AnswerInline]
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
if request.user.is_superuser or request.user.role == 'ADMIN':
|
||||||
|
return qs
|
||||||
|
if request.user.role == 'TEACHER':
|
||||||
|
return qs.filter(assignment__created_by=request.user)
|
||||||
|
return qs.filter(student=request.user)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class LmsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'lms'
|
||||||
|
verbose_name = 'Обучение (LMS)'
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from django import forms
|
||||||
|
class AnswersForm(forms.Form):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
questions = kwargs.pop('questions', [])
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for q in questions:
|
||||||
|
if q.question_type == 'TEXT_AUTO':
|
||||||
|
self.fields[f'answer_{q.id}'] = forms.CharField(
|
||||||
|
label='Ответ:',
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={
|
||||||
|
'class': 'w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Введите ваш ответ здесь...'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.fields[f'file_{q.id}'] = forms.FileField(
|
||||||
|
label='Прикрепить файл:',
|
||||||
|
required=False,
|
||||||
|
widget=forms.ClearableFileInput(attrs={
|
||||||
|
'class': 'block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.forms import UserCreationForm
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class StudentRegistrationForm(UserCreationForm):
|
||||||
|
class Meta(UserCreationForm.Meta):
|
||||||
|
model = User
|
||||||
|
fields = ('username', 'first_name')
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field in self.fields.values():
|
||||||
|
field.widget.attrs.update({
|
||||||
|
'class': 'w-full px-4 py-3 rounded-xl border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition shadow-sm text-gray-800'
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-18 18:49
|
||||||
|
|
||||||
|
import lms.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Assignment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=255)),
|
||||||
|
('description', models.TextField()),
|
||||||
|
('deadline', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Subject',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Submission',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('text_answer', models.TextField(blank=True, null=True)),
|
||||||
|
('file_answer', models.FileField(blank=True, null=True, upload_to='submissions/%Y/%m/%d/', validators=[lms.validators.validate_file_extension])),
|
||||||
|
('grade', models.CharField(blank=True, max_length=10, null=True)),
|
||||||
|
('teacher_feedback', models.TextField(blank=True, null=True)),
|
||||||
|
('status', models.CharField(choices=[('PENDING', 'Pending'), ('GRADED', 'Graded')], default='PENDING', max_length=20)),
|
||||||
|
('submitted_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-18 18:49
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lms', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='created_by',
|
||||||
|
field=models.ForeignKey(limit_choices_to={'role__in': ['TEACHER', 'ADMIN']}, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='subject',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='lms.subject'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='submission',
|
||||||
|
name='assignment',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='lms.assignment'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='submission',
|
||||||
|
name='student',
|
||||||
|
field=models.ForeignKey(limit_choices_to={'role': 'STUDENT'}, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-18 18:59
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import lms.validators
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lms', '0002_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='assignment',
|
||||||
|
options={'verbose_name': 'Задание', 'verbose_name_plural': 'Задания'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='subject',
|
||||||
|
options={'verbose_name': 'Предмет', 'verbose_name_plural': 'Предметы'},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='submission',
|
||||||
|
options={'verbose_name': 'Ответ ученика', 'verbose_name_plural': 'Ответы учеников'},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='created_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name='Дата создания'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='created_by',
|
||||||
|
field=models.ForeignKey(limit_choices_to={'role__in': ['TEACHER', 'ADMIN']}, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Кем создано'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='deadline',
|
||||||
|
field=models.DateTimeField(blank=True, null=True, verbose_name='Срок сдачи (Дедлайн)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='description',
|
||||||
|
field=models.TextField(verbose_name='Описание задания'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='subject',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='lms.subject', verbose_name='Предмет'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='title',
|
||||||
|
field=models.CharField(max_length=255, verbose_name='Название задания'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subject',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(max_length=100, verbose_name='Название предмета'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='submission',
|
||||||
|
name='assignment',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='lms.assignment', verbose_name='Задание'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='submission',
|
||||||
|
name='file_answer',
|
||||||
|
field=models.FileField(blank=True, null=True, upload_to='submissions/%Y/%m/%d/', validators=[lms.validators.validate_file_extension], verbose_name='Файл с ответом'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='submission',
|
||||||
|
name='grade',
|
||||||
|
field=models.CharField(blank=True, max_length=10, null=True, verbose_name='Оценка'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='submission',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('PENDING', 'Ожидает проверки'), ('GRADED', 'Проверено')], default='PENDING', max_length=20, verbose_name='Статус'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='submission',
|
||||||
|
name='student',
|
||||||
|
field=models.ForeignKey(limit_choices_to={'role': 'STUDENT'}, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Ученик'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='submission',
|
||||||
|
name='submitted_at',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name='Дата отправки'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='submission',
|
||||||
|
name='teacher_feedback',
|
||||||
|
field=models.TextField(blank=True, null=True, verbose_name='Комментарий учителя'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='submission',
|
||||||
|
name='text_answer',
|
||||||
|
field=models.TextField(blank=True, null=True, verbose_name='Текстовый ответ'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-18 20:40
|
||||||
|
|
||||||
|
import lms.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lms', '0003_alter_assignment_options_alter_subject_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='attached_file',
|
||||||
|
field=models.FileField(blank=True, null=True, upload_to='assignments/', validators=[lms.validators.validate_file_extension], verbose_name='Файл с заданием'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='correct_answer',
|
||||||
|
field=models.TextField(blank=True, null=True, verbose_name='Правильный ответ'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-18 20:47
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import lms.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lms', '0004_assignment_attached_file_assignment_correct_answer'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='attached_file',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='correct_answer',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='submission',
|
||||||
|
name='file_answer',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='submission',
|
||||||
|
name='text_answer',
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Question',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('text', models.TextField(verbose_name='Текст задачи')),
|
||||||
|
('attached_file', models.FileField(blank=True, null=True, upload_to='questions/', validators=[lms.validators.validate_file_extension], verbose_name='Файл (опционально)')),
|
||||||
|
('question_type', models.CharField(choices=[('TEXT_AUTO', 'Краткий ответ (автопроверка)'), ('FILE_MANUAL', 'Файл/Развернутый ответ (ручная проверка)')], default='TEXT_AUTO', max_length=20, verbose_name='Тип ответа')),
|
||||||
|
('correct_answer', models.TextField(blank=True, help_text='Оставьте пустым, если проверка ручная', null=True, verbose_name='Правильный ответ (для автопроверки)')),
|
||||||
|
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||||
|
('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='lms.assignment', verbose_name='Задание')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Задача',
|
||||||
|
'verbose_name_plural': 'Задачи',
|
||||||
|
'ordering': ['order', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Answer',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('text_answer', models.TextField(blank=True, null=True, verbose_name='Текстовый ответ')),
|
||||||
|
('file_answer', models.FileField(blank=True, null=True, upload_to='answers/%Y/%m/%d/', validators=[lms.validators.validate_file_extension], verbose_name='Файл с ответом')),
|
||||||
|
('is_correct', models.BooleanField(blank=True, null=True, verbose_name='Правильно?')),
|
||||||
|
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='lms.submission', verbose_name='Решение (Submission)')),
|
||||||
|
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='lms.question', verbose_name='Задача')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Ответ на задачу',
|
||||||
|
'verbose_name_plural': 'Ответы на задачи',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-18 20:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lms', '0005_remove_assignment_attached_file_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='answer',
|
||||||
|
name='comment',
|
||||||
|
field=models.TextField(blank=True, null=True, verbose_name='Комментарий'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='answer',
|
||||||
|
name='points',
|
||||||
|
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Баллы'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-19 07:29
|
||||||
|
|
||||||
|
import ckeditor_uploader.fields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lms', '0006_answer_comment_answer_points'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='description',
|
||||||
|
field=ckeditor_uploader.fields.RichTextUploadingField(blank=True, null=True, verbose_name='Описание задания'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='question',
|
||||||
|
name='correct_answer',
|
||||||
|
field=ckeditor_uploader.fields.RichTextUploadingField(blank=True, help_text='Оставьте пустым, если проверка ручная', null=True, verbose_name='Правильный ответ (для автопроверки)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='question',
|
||||||
|
name='text',
|
||||||
|
field=ckeditor_uploader.fields.RichTextUploadingField(verbose_name='Текст задачи'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-19 07:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lms', '0007_alter_assignment_description_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='question',
|
||||||
|
name='correct_answer',
|
||||||
|
field=models.CharField(blank=True, help_text='Оставьте пустым, если проверка ручная', max_length=255, null=True, verbose_name='Правильный ответ (для автопроверки)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-19 07:38
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lms', '0008_alter_question_correct_answer'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='assignment',
|
||||||
|
name='deadline',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# init
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from .validators import validate_file_extension
|
||||||
|
from ckeditor_uploader.fields import RichTextUploadingField
|
||||||
|
|
||||||
|
class Subject(models.Model):
|
||||||
|
name = models.CharField('Название предмета', max_length=100)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Предмет'
|
||||||
|
verbose_name_plural = 'Предметы'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Assignment(models.Model):
|
||||||
|
title = models.CharField('Название задания', max_length=255)
|
||||||
|
description = RichTextUploadingField('Описание задания', blank=True, null=True)
|
||||||
|
subject = models.ForeignKey(Subject, on_delete=models.CASCADE, verbose_name='Предмет')
|
||||||
|
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, limit_choices_to={'role__in': ['TEACHER', 'ADMIN']}, verbose_name='Кем создано')
|
||||||
|
created_at = models.DateTimeField('Дата создания', auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Задание'
|
||||||
|
verbose_name_plural = 'Задания'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} ({self.subject})"
|
||||||
|
|
||||||
|
class Question(models.Model):
|
||||||
|
TYPE_CHOICES = (
|
||||||
|
('TEXT_AUTO', 'Краткий ответ (автопроверка)'),
|
||||||
|
('FILE_MANUAL', 'Файл/Развернутый ответ (ручная проверка)'),
|
||||||
|
)
|
||||||
|
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='Оставьте пустым, если проверка ручная')
|
||||||
|
order = models.PositiveIntegerField('Порядок', default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Задача'
|
||||||
|
verbose_name_plural = 'Задачи'
|
||||||
|
ordering = ['order', 'id']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Задача {self.id} к {self.assignment.title}"
|
||||||
|
|
||||||
|
class Submission(models.Model):
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('PENDING', 'Ожидает проверки'),
|
||||||
|
('GRADED', 'Проверено'),
|
||||||
|
)
|
||||||
|
assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE, related_name='submissions', verbose_name='Задание')
|
||||||
|
student = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, limit_choices_to={'role': 'STUDENT'}, verbose_name='Ученик')
|
||||||
|
grade = models.CharField('Оценка', max_length=10, blank=True, null=True)
|
||||||
|
teacher_feedback = models.TextField('Комментарий учителя', blank=True, null=True)
|
||||||
|
status = models.CharField('Статус', max_length=20, choices=STATUS_CHOICES, default='PENDING')
|
||||||
|
submitted_at = models.DateTimeField('Дата отправки', auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Ответ ученика'
|
||||||
|
verbose_name_plural = 'Ответы учеников'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Ответ от {self.student.username} на {self.assignment.title}"
|
||||||
|
|
||||||
|
class Answer(models.Model):
|
||||||
|
submission = models.ForeignKey(Submission, on_delete=models.CASCADE, related_name='answers', verbose_name='Решение (Submission)')
|
||||||
|
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='answers', verbose_name='Задача')
|
||||||
|
text_answer = models.TextField('Текстовый ответ', blank=True, null=True)
|
||||||
|
file_answer = models.FileField('Файл с ответом', upload_to='answers/%Y/%m/%d/', blank=True, null=True, validators=[validate_file_extension])
|
||||||
|
is_correct = models.BooleanField('Правильно?', null=True, blank=True)
|
||||||
|
points = models.PositiveIntegerField('Баллы', blank=True, null=True)
|
||||||
|
comment = models.TextField('Комментарий', blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Ответ на задачу'
|
||||||
|
verbose_name_plural = 'Ответы на задачи'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Ответ на {self.question}"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
function toggleCorrectAnswer(row) {
|
||||||
|
const typeSelect = row.querySelector('select[name$="-question_type"]');
|
||||||
|
const correctAnswerInput = row.querySelector('[name$="-correct_answer"]');
|
||||||
|
|
||||||
|
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 (wrapper) {
|
||||||
|
if (typeSelect.value === 'FILE_MANUAL') {
|
||||||
|
wrapper.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
wrapper.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOrder() {
|
||||||
|
const rows = document.querySelectorAll('.inline-related:not(.empty-form)');
|
||||||
|
let counter = 1;
|
||||||
|
rows.forEach(row => {
|
||||||
|
const deleteInput = row.querySelector('input[name$="-DELETE"]');
|
||||||
|
if (deleteInput && deleteInput.checked) return;
|
||||||
|
|
||||||
|
if (row.style.display !== 'none') {
|
||||||
|
const orderInput = row.querySelector('input[name$="-order"]');
|
||||||
|
if (orderInput) {
|
||||||
|
orderInput.value = counter++;
|
||||||
|
let wrapper = row.querySelector('.field-order');
|
||||||
|
if (!wrapper) {
|
||||||
|
wrapper = orderInput.closest('.form-row') || orderInput.parentElement.parentElement;
|
||||||
|
}
|
||||||
|
if (wrapper) wrapper.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.addEventListener('change', function(e) {
|
||||||
|
if (e.target && e.target.matches('select[name$="-question_type"]')) {
|
||||||
|
const row = e.target.closest('.inline-related');
|
||||||
|
if (row) toggleCorrectAnswer(row);
|
||||||
|
}
|
||||||
|
if (e.target && e.target.matches('input[name$="-DELETE"]')) {
|
||||||
|
setTimeout(updateOrder, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function initAll() {
|
||||||
|
const rows = document.querySelectorAll('.inline-related:not(.empty-form)');
|
||||||
|
rows.forEach(row => {
|
||||||
|
toggleCorrectAnswer(row);
|
||||||
|
});
|
||||||
|
updateOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
initAll();
|
||||||
|
|
||||||
|
// Aggressive fallback for dynamically added inline rows in Unfold
|
||||||
|
setInterval(initAll, 1000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "admin/change_list.html" %}
|
||||||
|
{% load i18n admin_urls static admin_list unfold_list %}
|
||||||
|
|
||||||
|
{% block result_list %}
|
||||||
|
{% if actions_on_top %}
|
||||||
|
{% if cl.search_fields or action_form or cl.has_filters %}
|
||||||
|
<div class="bg-gray-50 flex flex-col gap-3 mb-4 p-3 rounded-md lg:flex-row dark:bg-gray-800">
|
||||||
|
{% block search %}
|
||||||
|
{% search_form cl %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block filters %}
|
||||||
|
{% if cl.has_filters %}
|
||||||
|
<a class="{% if cl.has_active_filters %}bg-primary-600 border-primary-600 text-white{% else %}bg-white dark:bg-gray-900 dark:border-gray-700{% endif %} border cursor-pointer flex font-medium group items-center px-3 py-2 rounded-md shadow-sm text-sm lg:ml-auto md:mt-0 {% if not cl.model_admin.list_filter_sheet %}2xl:hidden{% endif %}" x-on:click="filterOpen = true" x-on:keydown.escape.window="filterOpen = false">
|
||||||
|
{% trans "Filters" %}
|
||||||
|
|
||||||
|
<span class="material-symbols-outlined md-18 ml-auto -mr-1 pl-4 {% if cl.has_active_filters %}text-white{% else %}text-gray-400 group-hover:text-gray-500 dark:group-hover:text-gray-400 dark:text-gray-500{% endif %}">filter_list</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 pb-8">
|
||||||
|
<!-- PENDING COLUMN -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="font-bold text-lg mb-4 text-gray-700 dark:text-gray-300 flex items-center">
|
||||||
|
<span class="w-3 h-3 bg-yellow-400 rounded-full mr-2"></span>
|
||||||
|
Ожидает проверки
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{% for result in cl.result_list %}
|
||||||
|
{% if result.status == 'PENDING' %}
|
||||||
|
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow relative overflow-hidden group">
|
||||||
|
<div class="absolute top-0 left-0 w-1 h-full bg-yellow-400"></div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white text-base pl-2">{{ result.assignment.title }}</h3>
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400 pl-2">
|
||||||
|
<p class="flex items-center gap-1 mb-1"><span class="material-symbols-outlined text-[16px]">person</span> {{ result.student.first_name|default:result.student.username }}</p>
|
||||||
|
<p class="flex items-center gap-1"><span class="material-symbols-outlined text-[16px]">schedule</span> {{ result.submitted_at|date:"d M Y, H:i" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pl-2">
|
||||||
|
<a href="{% url 'admin:lms_submission_change' result.pk %}" class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-white bg-primary-600 hover:bg-primary-700 transition">
|
||||||
|
Проверить →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GRADED COLUMN -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="font-bold text-lg mb-4 text-gray-700 dark:text-gray-300 flex items-center">
|
||||||
|
<span class="w-3 h-3 bg-green-500 rounded-full mr-2"></span>
|
||||||
|
Проверено
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{% for result in cl.result_list %}
|
||||||
|
{% if result.status == 'GRADED' %}
|
||||||
|
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow relative overflow-hidden group">
|
||||||
|
<div class="absolute top-0 left-0 w-1 h-full bg-green-500"></div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white text-base pl-2">{{ result.assignment.title }}</h3>
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400 pl-2">
|
||||||
|
<p class="flex items-center gap-1 mb-1"><span class="material-symbols-outlined text-[16px]">person</span> {{ result.student.first_name|default:result.student.username }}</p>
|
||||||
|
<p class="flex items-center gap-1 font-medium text-green-600 dark:text-green-400"><span class="material-symbols-outlined text-[16px]">done_all</span> Оценка: {{ result.grade|default:"-" }}</p>
|
||||||
|
<p class="flex items-center gap-1 mt-1 text-xs"><span class="material-symbols-outlined text-[14px]">event</span> {{ result.submitted_at|date:"d M Y, H:i" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pl-2">
|
||||||
|
<a href="{% url 'admin:lms_submission_change' result.pk %}" class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 text-xs font-medium rounded text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition">
|
||||||
|
Посмотреть →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block pagination %}
|
||||||
|
{% pagination cl %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
{% extends 'lms/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}{{ assignment.title }} | ЕГЭ LMS{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<a href="{% url 'lms:dashboard' %}" class="text-blue-600 hover:underline mb-4 inline-block">← Назад к списку</a>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">{{ assignment.title }}</h1>
|
||||||
|
<div class="mt-2 text-sm text-gray-500 flex gap-4">
|
||||||
|
<span>Предмет: {{ assignment.subject.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-8">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Описание задания</h3>
|
||||||
|
<div class="prose max-w-none text-gray-700">
|
||||||
|
{{ assignment.description|safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">Ваш ответ</h3>
|
||||||
|
|
||||||
|
{% if submission %}
|
||||||
|
<div class="mb-6 p-4 rounded-lg border {% if submission.status == 'GRADED' %}bg-green-50 text-green-800 border-green-200{% else %}bg-yellow-50 text-yellow-800 border-yellow-200{% endif %}">
|
||||||
|
{% if submission.status == 'GRADED' %}
|
||||||
|
<h4 class="font-bold mb-2">Оценка: {{ submission.grade|default:"Не выставлена" }}</h4>
|
||||||
|
{% else %}
|
||||||
|
<p>Ваша работа отправлена и ожидает проверки.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if submission.teacher_feedback %}
|
||||||
|
<p class="mt-2"><strong>Общий комментарий:</strong> {{ submission.teacher_feedback }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="font-bold mb-4 text-gray-800">Ваши ответы на задачи:</h4>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for answer in submission.answers.all %}
|
||||||
|
<div class="p-4 bg-gray-50 border rounded-lg">
|
||||||
|
<h5 class="font-medium text-sm text-gray-500 mb-2">Задача {{ forloop.counter }}: {{ answer.question.text|striptags|truncatewords:10 }}</h5>
|
||||||
|
{% if answer.question.question_type == 'TEXT_AUTO' %}
|
||||||
|
<p class="text-gray-800"><strong>Ваш ответ:</strong> {{ answer.text_answer|default:"Нет" }}</p>
|
||||||
|
<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>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">Файл не прикреплен</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if answer.points is not None %}
|
||||||
|
<p class="mt-2 text-sm text-blue-800 font-medium">Баллы: {{ answer.points }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if answer.comment %}
|
||||||
|
<p class="mt-1 text-sm text-gray-700 bg-white p-2 rounded border"><strong>Комментарий:</strong> {{ answer.comment }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" enctype="multipart/form-data" class="space-y-6">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% for q, 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">
|
||||||
|
{{ q.text|safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if q.attached_file %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="{{ q.attached_file.url }}" class="inline-flex items-center px-3 py-1.5 bg-white border text-gray-700 rounded hover:bg-gray-50 transition text-sm" target="_blank">
|
||||||
|
<span class="material-icons mr-1 text-[16px]">attach_file</span> Скачать файл к задаче
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<button type="submit" class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-medium">
|
||||||
|
Отправить ответ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}ЕГЭ LMS{% endblock %}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Inter', sans-serif; background-color: #f8fafc; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-800 antialiased h-screen flex flex-col">
|
||||||
|
|
||||||
|
<!-- Navbar -->
|
||||||
|
<nav class="bg-white border-b border-gray-200 shadow-sm">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<a href="/" class="flex-shrink-0 flex items-center gap-2">
|
||||||
|
<span class="material-icons text-blue-600">school</span>
|
||||||
|
<span class="font-bold text-xl tracking-tight text-gray-900">ЕГЭ LMS</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<span class="text-sm text-gray-600">Привет, <b>{{ user.first_name|default:user.username }}</b></span>
|
||||||
|
<form action="{% url 'lms:logout' %}" method="post" class="inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="text-sm font-medium text-red-600 hover:text-red-800 transition">Выйти</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'lms:login' %}" class="text-sm font-medium text-blue-600 hover:text-blue-800 transition">Войти</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{% block content %}
|
||||||
|
{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{% extends 'lms/base.html' %}
|
||||||
|
{% load lms_tags %}
|
||||||
|
|
||||||
|
{% block title %}Мои задания | ЕГЭ LMS{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8 flex justify-between items-end border-b pb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Мои задания</h1>
|
||||||
|
<p class="text-gray-500 mt-1">Список всех доступных заданий для выполнения.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if assignments %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{% for assignment in assignments %}
|
||||||
|
{% get_submission submissions assignment.id as sub %}
|
||||||
|
<div class="bg-white rounded-2xl shadow-sm hover:shadow-md border border-gray-100 transition-all duration-200 overflow-hidden flex flex-col">
|
||||||
|
<div class="p-6 flex-1">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{{ assignment.subject.name }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if sub %}
|
||||||
|
{% if sub.status == 'GRADED' %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<span class="material-icons text-[12px] mr-1">done_all</span> Проверено ({{ sub.grade|default:"-" }})
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
<span class="material-icons text-[12px] mr-1">schedule</span> Ожидает проверки
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
Не сдано
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 mb-2 line-clamp-2">
|
||||||
|
{{ assignment.title }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-500 text-sm line-clamp-3 mb-4">
|
||||||
|
{{ assignment.description|striptags|truncatewords:20 }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-400 mt-auto flex flex-col gap-1">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="material-icons text-[14px]">event</span>
|
||||||
|
Добавлено: {{ assignment.created_at|date:"d M Y" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 p-4 border-t border-gray-100">
|
||||||
|
<a href="{% url 'lms:assignment_detail' assignment.pk %}" class="w-full inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 shadow-sm transition-colors">
|
||||||
|
{% if sub %}Смотреть ответ{% else %}Выполнить задание{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-20">
|
||||||
|
<span class="material-icons text-gray-300 text-6xl mb-4">assignment_turned_in</span>
|
||||||
|
<h3 class="text-xl font-medium text-gray-900 mb-1">Пока заданий нет</h3>
|
||||||
|
<p class="text-gray-500">Учитель еще не добавил новые задания.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Платформа ЕГЭ LMS</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 flex items-center justify-center h-screen">
|
||||||
|
<div class="text-center p-8 bg-white shadow-lg rounded-xl max-w-md w-full">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 mb-4">Платформа ЕГЭ</h1>
|
||||||
|
<p class="text-gray-600 mb-6">Добро пожаловать в систему для проверки домашних заданий.</p>
|
||||||
|
|
||||||
|
<a href="{% url 'lms:login' %}" class="block w-full py-3 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition">
|
||||||
|
Войти в систему
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends 'lms/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Вход | ЕГЭ LMS{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-center h-[calc(100vh-8rem)]">
|
||||||
|
<div class="bg-white p-8 rounded-2xl shadow-lg max-w-md w-full border border-gray-100">
|
||||||
|
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<span class="material-icons text-blue-600 text-5xl mb-2">lock</span>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900">Вход в систему</h2>
|
||||||
|
<p class="text-gray-500 text-sm mt-1">Пожалуйста, авторизуйтесь для доступа к заданиям</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="space-y-6">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="bg-red-50 text-red-600 p-3 rounded-lg text-sm border border-red-200">
|
||||||
|
Имя пользователя или пароль введены неверно.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="id_username" class="block text-sm font-medium text-gray-700 mb-1">Имя пользователя</label>
|
||||||
|
<input type="text" name="username" id="id_username" required
|
||||||
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition shadow-sm text-gray-800">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="id_password" class="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||||
|
<input type="password" name="password" id="id_password" required
|
||||||
|
class="w-full px-4 py-3 rounded-xl border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition shadow-sm text-gray-800">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-4 rounded-xl shadow-md hover:shadow-lg transition flex justify-center items-center gap-2">
|
||||||
|
Войти <span class="material-icons text-sm">login</span>
|
||||||
|
</button>
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center text-sm text-gray-500">
|
||||||
|
Нет аккаунта? <a href="{% url 'lms:register' %}" class="text-blue-600 hover:underline">Зарегистрироваться</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{% extends 'lms/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Регистрация | ЕГЭ LMS{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-center min-h-[calc(100vh-8rem)] py-8">
|
||||||
|
<div class="bg-white p-8 rounded-2xl shadow-lg max-w-md w-full border border-gray-100">
|
||||||
|
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<span class="material-icons text-blue-600 text-5xl mb-2">person_add</span>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900">Регистрация ученика</h2>
|
||||||
|
<p class="text-gray-500 text-sm mt-1">Создайте аккаунт для доступа к заданиям</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="space-y-6">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="bg-red-50 text-red-600 p-3 rounded-lg text-sm border border-red-200">
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for field in form %}
|
||||||
|
<div>
|
||||||
|
<label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.errors %}
|
||||||
|
<p class="text-red-500 text-xs mt-1">{{ field.errors.0 }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<p class="text-gray-400 text-xs mt-1">{{ field.help_text }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-4 rounded-xl shadow-md hover:shadow-lg transition flex justify-center items-center gap-2">
|
||||||
|
Зарегистрироваться <span class="material-icons text-sm">person_add</span>
|
||||||
|
</button>
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center text-sm text-gray-500">
|
||||||
|
Уже есть аккаунт? <a href="{% url 'lms:login' %}" class="text-blue-600 hover:underline">Войти</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# init
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def get_submission(submissions_dict, assignment_id):
|
||||||
|
return submissions_dict.get(assignment_id)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'lms'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('register/', views.register, name='register'),
|
||||||
|
path('login/', auth_views.LoginView.as_view(template_name='lms/login.html', redirect_authenticated_user=True), name='login'),
|
||||||
|
path('logout/', auth_views.LogoutView.as_view(next_page='/'), name='logout'),
|
||||||
|
path('dashboard/', views.dashboard, name='dashboard'),
|
||||||
|
path('assignment/<int:pk>/', views.assignment_detail, name='assignment_detail'),
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import os
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
def validate_file_extension(value):
|
||||||
|
ext = os.path.splitext(value.name)[1]
|
||||||
|
valid_extensions = ['.pdf', '.jpg', '.jpeg', '.png']
|
||||||
|
if not ext.lower() in valid_extensions:
|
||||||
|
raise ValidationError('Unsupported file extension. Only PDF, JPG, and PNG are allowed.')
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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
|
||||||
|
from .forms import AnswersForm, StudentRegistrationForm
|
||||||
|
from django.contrib.auth import login
|
||||||
|
def register(request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return redirect('lms:dashboard')
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = StudentRegistrationForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
user = form.save(commit=False)
|
||||||
|
user.role = 'STUDENT'
|
||||||
|
user.save()
|
||||||
|
login(request, user)
|
||||||
|
return redirect('lms:dashboard')
|
||||||
|
else:
|
||||||
|
form = StudentRegistrationForm()
|
||||||
|
return render(request, 'lms/register.html', {'form': form})
|
||||||
|
|
||||||
|
def is_student(user):
|
||||||
|
return user.is_authenticated and user.role == 'STUDENT'
|
||||||
|
|
||||||
|
def home(request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
if getattr(request.user, 'role', None) == 'STUDENT':
|
||||||
|
return redirect('lms:dashboard')
|
||||||
|
else:
|
||||||
|
return redirect('/admin/')
|
||||||
|
return render(request, 'lms/home.html')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@user_passes_test(is_student, login_url='/login/')
|
||||||
|
def dashboard(request):
|
||||||
|
# Fetch assignments (could be all, or specific to student's classes)
|
||||||
|
# For now, let's fetch all assignments.
|
||||||
|
assignments = Assignment.objects.all().order_by('-created_at')
|
||||||
|
|
||||||
|
# Check which ones the student has submitted
|
||||||
|
submissions = Submission.objects.filter(student=request.user)
|
||||||
|
submitted_assignment_ids = submissions.values_list('assignment_id', flat=True)
|
||||||
|
|
||||||
|
return render(request, 'lms/dashboard.html', {
|
||||||
|
'assignments': assignments,
|
||||||
|
'submitted_assignment_ids': submitted_assignment_ids,
|
||||||
|
'submissions': {sub.assignment_id: sub for sub in submissions}
|
||||||
|
})
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@user_passes_test(is_student, login_url='/login/')
|
||||||
|
def assignment_detail(request, pk):
|
||||||
|
assignment = get_object_or_404(Assignment, pk=pk)
|
||||||
|
questions = assignment.questions.all()
|
||||||
|
|
||||||
|
# Try to get existing submission
|
||||||
|
submission = Submission.objects.filter(assignment=assignment, student=request.user).first()
|
||||||
|
|
||||||
|
if submission:
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Do not allow updating
|
||||||
|
return redirect('lms:dashboard')
|
||||||
|
form = None
|
||||||
|
else:
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = AnswersForm(request.POST, request.FILES, questions=questions)
|
||||||
|
if form.is_valid():
|
||||||
|
submission = Submission.objects.create(assignment=assignment, student=request.user)
|
||||||
|
|
||||||
|
all_auto = True
|
||||||
|
total_points = 0
|
||||||
|
max_points = 0
|
||||||
|
|
||||||
|
for q in questions:
|
||||||
|
answer_obj = Answer(submission=submission, question=q)
|
||||||
|
if q.question_type == 'TEXT_AUTO':
|
||||||
|
val = form.cleaned_data.get(f'answer_{q.id}', '')
|
||||||
|
answer_obj.text_answer = val
|
||||||
|
if q.correct_answer:
|
||||||
|
correct = strip_tags(q.correct_answer).strip().lower()
|
||||||
|
answer_obj.is_correct = (val.strip().lower() == correct)
|
||||||
|
answer_obj.points = 1 if answer_obj.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}')
|
||||||
|
if val:
|
||||||
|
answer_obj.file_answer = val
|
||||||
|
answer_obj.save()
|
||||||
|
|
||||||
|
if all_auto and max_points > 0:
|
||||||
|
submission.status = 'GRADED'
|
||||||
|
submission.grade = f"{total_points}/{max_points}"
|
||||||
|
else:
|
||||||
|
submission.status = 'PENDING'
|
||||||
|
submission.save()
|
||||||
|
return redirect('lms:dashboard')
|
||||||
|
else:
|
||||||
|
form = AnswersForm(questions=questions)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
return render(request, 'lms/assignment_detail.html', {
|
||||||
|
'assignment': assignment,
|
||||||
|
'form': form,
|
||||||
|
'submission': submission,
|
||||||
|
'questions': questions,
|
||||||
|
'question_fields': question_fields,
|
||||||
|
})
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for lmsege project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.1/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'lmsege.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
Django settings for lmsege project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 6.1.1.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.1/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/6.1/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import environ
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
env = environ.Env(
|
||||||
|
DEBUG=(bool, False)
|
||||||
|
)
|
||||||
|
environ.Env.read_env(os.path.join(BASE_DIR.parent, '.env'))
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = env('SECRET_KEY', default='django-insecure-fallback-key')
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = env('DEBUG')
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['*'])
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'unfold',
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'users',
|
||||||
|
'lms',
|
||||||
|
'ckeditor',
|
||||||
|
'ckeditor_uploader',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'lmsege.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'lmsege.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/6.1/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db_data' / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/6.1/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/6.1/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'ru-RU'
|
||||||
|
|
||||||
|
TIME_ZONE = 'Europe/Moscow'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/6.1/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
MEDIA_URL = 'media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
|
||||||
|
# Email
|
||||||
|
# https://docs.djangoproject.com/en/6.1/topics/email/#topic-email-configuration
|
||||||
|
|
||||||
|
MAILERS = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django.core.mail.backends.console.EmailBackend',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = 'users.CustomUser'
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_URL = '/'
|
||||||
|
LOGOUT_REDIRECT_URL = '/'
|
||||||
|
|
||||||
|
CKEDITOR_UPLOAD_PATH = "uploads/"
|
||||||
|
CKEDITOR_CONFIGS = {
|
||||||
|
'default': {
|
||||||
|
'toolbar': 'full',
|
||||||
|
'height': 300,
|
||||||
|
'width': '100%',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
URL configuration for lmsege project.
|
||||||
|
|
||||||
|
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||||
|
https://docs.djangoproject.com/en/6.1/topics/http/urls/
|
||||||
|
Examples:
|
||||||
|
Function views
|
||||||
|
1. Add an import: from my_app import views
|
||||||
|
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||||
|
Class-based views
|
||||||
|
1. Add an import: from other_app.views import Home
|
||||||
|
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||||
|
Including another URLconf
|
||||||
|
1. Import the include() function: from django.urls import include, path
|
||||||
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
|
||||||
|
from lms.views import home
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('', home, name='home'),
|
||||||
|
path('', include('lms.urls')),
|
||||||
|
path('ckeditor/', include('ckeditor_uploader.urls')),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for lmsege project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.1/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'lmsege.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'lmsege.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# init
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from unfold.admin import ModelAdmin
|
||||||
|
from .models import CustomUser
|
||||||
|
|
||||||
|
@admin.register(CustomUser)
|
||||||
|
class CustomUserAdmin(UserAdmin, ModelAdmin):
|
||||||
|
list_display = ('username', 'email', 'first_name', 'last_name', 'role', 'is_staff')
|
||||||
|
fieldsets = UserAdmin.fieldsets + (
|
||||||
|
('Настройка роли', {'fields': ('role',)}),
|
||||||
|
)
|
||||||
|
add_fieldsets = UserAdmin.add_fieldsets + (
|
||||||
|
('Настройка роли', {'fields': ('role',)}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class UsersConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'users'
|
||||||
|
verbose_name = 'Пользователи'
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-18 18:49
|
||||||
|
|
||||||
|
import django.contrib.auth.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CustomUser',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||||
|
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||||
|
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||||
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||||
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
|
('role', models.CharField(choices=[('ADMIN', 'Admin'), ('TEACHER', 'Teacher'), ('STUDENT', 'Student')], default='STUDENT', max_length=10)),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'user',
|
||||||
|
'verbose_name_plural': 'users',
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', django.contrib.auth.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.1.1 on 2026-09-18 18:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='customuser',
|
||||||
|
options={'verbose_name': 'Пользователь', 'verbose_name_plural': 'Пользователи'},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customuser',
|
||||||
|
name='role',
|
||||||
|
field=models.CharField(choices=[('ADMIN', 'Администратор'), ('TEACHER', 'Учитель'), ('STUDENT', 'Ученик')], default='STUDENT', max_length=10, verbose_name='Роль'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# init
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
class CustomUser(AbstractUser):
|
||||||
|
ROLE_CHOICES = (
|
||||||
|
('ADMIN', 'Администратор'),
|
||||||
|
('TEACHER', 'Учитель'),
|
||||||
|
('STUDENT', 'Ученик'),
|
||||||
|
)
|
||||||
|
role = models.CharField('Роль', max_length=10, choices=ROLE_CHOICES, default='STUDENT')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Пользователь'
|
||||||
|
verbose_name_plural = 'Пользователи'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.username} ({self.get_role_display()})"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Django==5.1.1
|
||||||
|
django-environ==0.11.2
|
||||||
|
django-unfold==0.41.0
|
||||||
|
Pillow==10.4.0
|
||||||
|
gunicorn==23.0.0
|
||||||
|
whitenoise==6.7.0
|
||||||
|
django-ckeditor==6.7.1
|
||||||
Reference in New Issue
Block a user