commit 0e11a41d2960bd28aec47fbf8b06a74bf8850a5a Author: Dmitry Belan Date: Sat Sep 19 11:43:07 2026 +0300 f diff --git a/.env b/.env new file mode 100644 index 0000000..5abd099 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +DEBUG=True +SECRET_KEY=django-insecure-ege-lms-secret-key-12345 +ALLOWED_HOSTS=* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6e1e194 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ba5d2c3 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/lmsege/lms/__init__.py b/lmsege/lms/__init__.py new file mode 100644 index 0000000..a6131c1 --- /dev/null +++ b/lmsege/lms/__init__.py @@ -0,0 +1 @@ +# init diff --git a/lmsege/lms/admin.py b/lmsege/lms/admin.py new file mode 100644 index 0000000..d6023c4 --- /dev/null +++ b/lmsege/lms/admin.py @@ -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('Скачать файл', 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) diff --git a/lmsege/lms/apps.py b/lmsege/lms/apps.py new file mode 100644 index 0000000..5617ccc --- /dev/null +++ b/lmsege/lms/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class LmsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'lms' + verbose_name = 'Обучение (LMS)' diff --git a/lmsege/lms/forms.py b/lmsege/lms/forms.py new file mode 100644 index 0000000..97a68b5 --- /dev/null +++ b/lmsege/lms/forms.py @@ -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' + }) diff --git a/lmsege/lms/migrations/0001_initial.py b/lmsege/lms/migrations/0001_initial.py new file mode 100644 index 0000000..a409849 --- /dev/null +++ b/lmsege/lms/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/lmsege/lms/migrations/0002_initial.py b/lmsege/lms/migrations/0002_initial.py new file mode 100644 index 0000000..f384165 --- /dev/null +++ b/lmsege/lms/migrations/0002_initial.py @@ -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), + ), + ] diff --git a/lmsege/lms/migrations/0003_alter_assignment_options_alter_subject_options_and_more.py b/lmsege/lms/migrations/0003_alter_assignment_options_alter_subject_options_and_more.py new file mode 100644 index 0000000..769bb7c --- /dev/null +++ b/lmsege/lms/migrations/0003_alter_assignment_options_alter_subject_options_and_more.py @@ -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='Текстовый ответ'), + ), + ] diff --git a/lmsege/lms/migrations/0004_assignment_attached_file_assignment_correct_answer.py b/lmsege/lms/migrations/0004_assignment_attached_file_assignment_correct_answer.py new file mode 100644 index 0000000..b29afe9 --- /dev/null +++ b/lmsege/lms/migrations/0004_assignment_attached_file_assignment_correct_answer.py @@ -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='Правильный ответ'), + ), + ] diff --git a/lmsege/lms/migrations/0005_remove_assignment_attached_file_and_more.py b/lmsege/lms/migrations/0005_remove_assignment_attached_file_and_more.py new file mode 100644 index 0000000..ba90fdf --- /dev/null +++ b/lmsege/lms/migrations/0005_remove_assignment_attached_file_and_more.py @@ -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': 'Ответы на задачи', + }, + ), + ] diff --git a/lmsege/lms/migrations/0006_answer_comment_answer_points.py b/lmsege/lms/migrations/0006_answer_comment_answer_points.py new file mode 100644 index 0000000..0d70db1 --- /dev/null +++ b/lmsege/lms/migrations/0006_answer_comment_answer_points.py @@ -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='Баллы'), + ), + ] diff --git a/lmsege/lms/migrations/0007_alter_assignment_description_and_more.py b/lmsege/lms/migrations/0007_alter_assignment_description_and_more.py new file mode 100644 index 0000000..043b65b --- /dev/null +++ b/lmsege/lms/migrations/0007_alter_assignment_description_and_more.py @@ -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='Текст задачи'), + ), + ] diff --git a/lmsege/lms/migrations/0008_alter_question_correct_answer.py b/lmsege/lms/migrations/0008_alter_question_correct_answer.py new file mode 100644 index 0000000..da70cf1 --- /dev/null +++ b/lmsege/lms/migrations/0008_alter_question_correct_answer.py @@ -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='Правильный ответ (для автопроверки)'), + ), + ] diff --git a/lmsege/lms/migrations/0009_remove_assignment_deadline.py b/lmsege/lms/migrations/0009_remove_assignment_deadline.py new file mode 100644 index 0000000..35168d7 --- /dev/null +++ b/lmsege/lms/migrations/0009_remove_assignment_deadline.py @@ -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', + ), + ] diff --git a/lmsege/lms/migrations/__init__.py b/lmsege/lms/migrations/__init__.py new file mode 100644 index 0000000..a6131c1 --- /dev/null +++ b/lmsege/lms/migrations/__init__.py @@ -0,0 +1 @@ +# init diff --git a/lmsege/lms/models.py b/lmsege/lms/models.py new file mode 100644 index 0000000..748d2eb --- /dev/null +++ b/lmsege/lms/models.py @@ -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}" diff --git a/lmsege/lms/static/js/admin_assignment.js b/lmsege/lms/static/js/admin_assignment.js new file mode 100644 index 0000000..2583be5 --- /dev/null +++ b/lmsege/lms/static/js/admin_assignment.js @@ -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); +}); diff --git a/lmsege/lms/templates/admin/lms/submission/change_list.html b/lmsege/lms/templates/admin/lms/submission/change_list.html new file mode 100644 index 0000000..da64928 --- /dev/null +++ b/lmsege/lms/templates/admin/lms/submission/change_list.html @@ -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 %} +
+ {% block search %} + {% search_form cl %} + {% endblock %} + + {% block filters %} + {% if cl.has_filters %} + + {% trans "Filters" %} + + filter_list + + {% endif %} + {% endblock %} +
+ {% endif %} + {% endif %} + +
+ +
+

+ + Ожидает проверки +

+
+ {% for result in cl.result_list %} + {% if result.status == 'PENDING' %} +
+
+

{{ result.assignment.title }}

+
+

person {{ result.student.first_name|default:result.student.username }}

+

schedule {{ result.submitted_at|date:"d M Y, H:i" }}

+
+ +
+ {% endif %} + {% endfor %} +
+
+ + +
+

+ + Проверено +

+
+ {% for result in cl.result_list %} + {% if result.status == 'GRADED' %} +
+
+

{{ result.assignment.title }}

+
+

person {{ result.student.first_name|default:result.student.username }}

+

done_all Оценка: {{ result.grade|default:"-" }}

+

event {{ result.submitted_at|date:"d M Y, H:i" }}

+
+ +
+ {% endif %} + {% endfor %} +
+
+
+ + {% block pagination %} + {% pagination cl %} + {% endblock %} +{% endblock %} diff --git a/lmsege/lms/templates/lms/assignment_detail.html b/lmsege/lms/templates/lms/assignment_detail.html new file mode 100644 index 0000000..a8f5814 --- /dev/null +++ b/lmsege/lms/templates/lms/assignment_detail.html @@ -0,0 +1,108 @@ +{% extends 'lms/base.html' %} + +{% block title %}{{ assignment.title }} | ЕГЭ LMS{% endblock %} + +{% block content %} +
+ ← Назад к списку +

{{ assignment.title }}

+
+ Предмет: {{ assignment.subject.name }} +
+
+ +
+

Описание задания

+
+ {{ assignment.description|safe }} +
+ +
+ +
+

Ваш ответ

+ + {% if submission %} +
+ {% if submission.status == 'GRADED' %} +

Оценка: {{ submission.grade|default:"Не выставлена" }}

+ {% else %} +

Ваша работа отправлена и ожидает проверки.

+ {% endif %} + + {% if submission.teacher_feedback %} +

Общий комментарий: {{ submission.teacher_feedback }}

+ {% endif %} +
+ +

Ваши ответы на задачи:

+
+ {% for answer in submission.answers.all %} +
+
Задача {{ forloop.counter }}: {{ answer.question.text|striptags|truncatewords:10 }}
+ {% if answer.question.question_type == 'TEXT_AUTO' %} +

Ваш ответ: {{ answer.text_answer|default:"Нет" }}

+

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

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

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

+ {% else %} +

Файл не прикреплен

+ {% endif %} + {% endif %} + + {% if answer.points is not None %} +

Баллы: {{ answer.points }}

+ {% endif %} + {% if answer.comment %} +

Комментарий: {{ answer.comment }}

+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+ {% csrf_token %} + + {% for q, field in question_fields %} +
+

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

+
+ {{ q.text|safe }} +
+ + {% if q.attached_file %} + + {% endif %} + +
+ + {{ field }} + {% if field.errors %} +

{{ field.errors.0 }}

+ {% endif %} +
+
+ {% endfor %} + +
+ +
+
+ {% endif %} +
+{% endblock %} diff --git a/lmsege/lms/templates/lms/base.html b/lmsege/lms/templates/lms/base.html new file mode 100644 index 0000000..e6a376f --- /dev/null +++ b/lmsege/lms/templates/lms/base.html @@ -0,0 +1,47 @@ + + + + + + {% block title %}ЕГЭ LMS{% endblock %} + + + + + + + + + + +
+ {% block content %} + {% endblock %} +
+ + + diff --git a/lmsege/lms/templates/lms/dashboard.html b/lmsege/lms/templates/lms/dashboard.html new file mode 100644 index 0000000..691555a --- /dev/null +++ b/lmsege/lms/templates/lms/dashboard.html @@ -0,0 +1,72 @@ +{% extends 'lms/base.html' %} +{% load lms_tags %} + +{% block title %}Мои задания | ЕГЭ LMS{% endblock %} + +{% block content %} +
+
+

Мои задания

+

Список всех доступных заданий для выполнения.

+
+
+ +{% if assignments %} +
+ {% for assignment in assignments %} + {% get_submission submissions assignment.id as sub %} +
+
+
+ + {{ assignment.subject.name }} + + + {% if sub %} + {% if sub.status == 'GRADED' %} + + done_all Проверено ({{ sub.grade|default:"-" }}) + + {% else %} + + schedule Ожидает проверки + + {% endif %} + {% else %} + + Не сдано + + {% endif %} +
+ +

+ {{ assignment.title }} +

+

+ {{ assignment.description|striptags|truncatewords:20 }} +

+ +
+ + event + Добавлено: {{ assignment.created_at|date:"d M Y" }} + +
+
+ +
+ {% endfor %} +
+{% else %} +
+ assignment_turned_in +

Пока заданий нет

+

Учитель еще не добавил новые задания.

+
+{% endif %} + +{% endblock %} diff --git a/lmsege/lms/templates/lms/home.html b/lmsege/lms/templates/lms/home.html new file mode 100644 index 0000000..f13a151 --- /dev/null +++ b/lmsege/lms/templates/lms/home.html @@ -0,0 +1,19 @@ + + + + + + Платформа ЕГЭ LMS + + + +
+

Платформа ЕГЭ

+

Добро пожаловать в систему для проверки домашних заданий.

+ + + Войти в систему + +
+ + diff --git a/lmsege/lms/templates/lms/login.html b/lmsege/lms/templates/lms/login.html new file mode 100644 index 0000000..e69d253 --- /dev/null +++ b/lmsege/lms/templates/lms/login.html @@ -0,0 +1,48 @@ +{% extends 'lms/base.html' %} + +{% block title %}Вход | ЕГЭ LMS{% endblock %} + +{% block content %} +
+
+ +
+ lock +

Вход в систему

+

Пожалуйста, авторизуйтесь для доступа к заданиям

+
+ +
+ {% csrf_token %} + + {% if form.errors %} +
+ Имя пользователя или пароль введены неверно. +
+ {% endif %} + +
+ + +
+ +
+ + +
+ + + +
+ +
+ Нет аккаунта? Зарегистрироваться +
+
+
+{% endblock %} diff --git a/lmsege/lms/templates/lms/register.html b/lmsege/lms/templates/lms/register.html new file mode 100644 index 0000000..41def42 --- /dev/null +++ b/lmsege/lms/templates/lms/register.html @@ -0,0 +1,49 @@ +{% extends 'lms/base.html' %} + +{% block title %}Регистрация | ЕГЭ LMS{% endblock %} + +{% block content %} +
+
+ +
+ person_add +

Регистрация ученика

+

Создайте аккаунт для доступа к заданиям

+
+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +
+ {{ form.non_field_errors }} +
+ {% endif %} + + {% for field in form %} +
+ + {{ field }} + {% if field.errors %} +

{{ field.errors.0 }}

+ {% endif %} + {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} +
+ {% endfor %} + + + +
+ +
+ Уже есть аккаунт? Войти +
+
+
+{% endblock %} diff --git a/lmsege/lms/templatetags/__init__.py b/lmsege/lms/templatetags/__init__.py new file mode 100644 index 0000000..a6131c1 --- /dev/null +++ b/lmsege/lms/templatetags/__init__.py @@ -0,0 +1 @@ +# init diff --git a/lmsege/lms/templatetags/lms_tags.py b/lmsege/lms/templatetags/lms_tags.py new file mode 100644 index 0000000..c8c023d --- /dev/null +++ b/lmsege/lms/templatetags/lms_tags.py @@ -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) diff --git a/lmsege/lms/urls.py b/lmsege/lms/urls.py new file mode 100644 index 0000000..1a36d51 --- /dev/null +++ b/lmsege/lms/urls.py @@ -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//', views.assignment_detail, name='assignment_detail'), +] diff --git a/lmsege/lms/validators.py b/lmsege/lms/validators.py new file mode 100644 index 0000000..4dc2706 --- /dev/null +++ b/lmsege/lms/validators.py @@ -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.') diff --git a/lmsege/lms/views.py b/lmsege/lms/views.py new file mode 100644 index 0000000..90562ae --- /dev/null +++ b/lmsege/lms/views.py @@ -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, + }) diff --git a/lmsege/lmsege/__init__.py b/lmsege/lmsege/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lmsege/lmsege/__pycache__/__init__.cpython-314.pyc b/lmsege/lmsege/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..a374803 Binary files /dev/null and b/lmsege/lmsege/__pycache__/__init__.cpython-314.pyc differ diff --git a/lmsege/lmsege/__pycache__/settings.cpython-314.pyc b/lmsege/lmsege/__pycache__/settings.cpython-314.pyc new file mode 100644 index 0000000..8973d36 Binary files /dev/null and b/lmsege/lmsege/__pycache__/settings.cpython-314.pyc differ diff --git a/lmsege/lmsege/asgi.py b/lmsege/lmsege/asgi.py new file mode 100644 index 0000000..aef8fb5 --- /dev/null +++ b/lmsege/lmsege/asgi.py @@ -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() diff --git a/lmsege/lmsege/settings.py b/lmsege/lmsege/settings.py new file mode 100644 index 0000000..ec81fae --- /dev/null +++ b/lmsege/lmsege/settings.py @@ -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%', + }, +} diff --git a/lmsege/lmsege/urls.py b/lmsege/lmsege/urls.py new file mode 100644 index 0000000..62d4ab0 --- /dev/null +++ b/lmsege/lmsege/urls.py @@ -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) + diff --git a/lmsege/lmsege/wsgi.py b/lmsege/lmsege/wsgi.py new file mode 100644 index 0000000..8e5c1a1 --- /dev/null +++ b/lmsege/lmsege/wsgi.py @@ -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() diff --git a/lmsege/manage.py b/lmsege/manage.py new file mode 100755 index 0000000..01ff155 --- /dev/null +++ b/lmsege/manage.py @@ -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() diff --git a/lmsege/users/__init__.py b/lmsege/users/__init__.py new file mode 100644 index 0000000..a6131c1 --- /dev/null +++ b/lmsege/users/__init__.py @@ -0,0 +1 @@ +# init diff --git a/lmsege/users/admin.py b/lmsege/users/admin.py new file mode 100644 index 0000000..5d23a3d --- /dev/null +++ b/lmsege/users/admin.py @@ -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',)}), + ) diff --git a/lmsege/users/apps.py b/lmsege/users/apps.py new file mode 100644 index 0000000..5630929 --- /dev/null +++ b/lmsege/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'users' + verbose_name = 'Пользователи' diff --git a/lmsege/users/migrations/0001_initial.py b/lmsege/users/migrations/0001_initial.py new file mode 100644 index 0000000..6fd1c81 --- /dev/null +++ b/lmsege/users/migrations/0001_initial.py @@ -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()), + ], + ), + ] diff --git a/lmsege/users/migrations/0002_alter_customuser_options_alter_customuser_role.py b/lmsege/users/migrations/0002_alter_customuser_options_alter_customuser_role.py new file mode 100644 index 0000000..0555baf --- /dev/null +++ b/lmsege/users/migrations/0002_alter_customuser_options_alter_customuser_role.py @@ -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='Роль'), + ), + ] diff --git a/lmsege/users/migrations/__init__.py b/lmsege/users/migrations/__init__.py new file mode 100644 index 0000000..a6131c1 --- /dev/null +++ b/lmsege/users/migrations/__init__.py @@ -0,0 +1 @@ +# init diff --git a/lmsege/users/models.py b/lmsege/users/models.py new file mode 100644 index 0000000..b27a1e4 --- /dev/null +++ b/lmsege/users/models.py @@ -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()})" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e653009 --- /dev/null +++ b/requirements.txt @@ -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