diff --git a/courses/__init__.py b/courses/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/courses/admin.py b/courses/admin.py new file mode 100644 index 00000000..3036c358 --- /dev/null +++ b/courses/admin.py @@ -0,0 +1,711 @@ +from django.contrib import admin +from django import forms +from django.forms.models import BaseInlineFormSet +from types import MethodType + +from files.models import UserFile +from files.service import CDN, SelectelSwiftStorage + +from .models import ( + Course, + CourseLesson, + CourseModule, + CourseTask, + CourseTaskOption, + UserCourseProgress, + UserLessonProgress, + UserModuleProgress, + UserTaskAnswer, + UserTaskAnswerFile, + UserTaskAnswerOption, +) + +# Admin-only captions for sections in app index +CourseModule._meta.verbose_name = "Модуль" +CourseModule._meta.verbose_name_plural = "Модули" +CourseLesson._meta.verbose_name = "Урок" +CourseLesson._meta.verbose_name_plural = "Уроки" +CourseTask._meta.verbose_name = "Задание" +CourseTask._meta.verbose_name_plural = "Задания" +CourseTaskOption._meta.verbose_name = "Вариант ответа" +CourseTaskOption._meta.verbose_name_plural = "Варианты ответов" +UserTaskAnswer._meta.verbose_name = "Ответ пользователя" +UserTaskAnswer._meta.verbose_name_plural = "Ответы пользователя" +UserTaskAnswerOption._meta.verbose_name = "Выбранный вариант" +UserTaskAnswerOption._meta.verbose_name_plural = "Выбранные варианты" +UserTaskAnswerFile._meta.verbose_name = "Файл" +UserTaskAnswerFile._meta.verbose_name_plural = "Файлы" +UserCourseProgress._meta.verbose_name = "Прогресс курса" +UserCourseProgress._meta.verbose_name_plural = "Прогресс курсов" +UserModuleProgress._meta.verbose_name = "Прогресс модуля" +UserModuleProgress._meta.verbose_name_plural = "Прогресс модулей" +UserLessonProgress._meta.verbose_name = "Прогресс урока" +UserLessonProgress._meta.verbose_name_plural = "Прогресс уроков" + +_COURSES_MODEL_ORDER = { + "Course": 1, + "CourseModule": 2, + "CourseLesson": 3, + "CourseTask": 4, + "CourseTaskOption": 5, + "UserTaskAnswer": 6, + "UserTaskAnswerOption": 7, + "UserTaskAnswerFile": 8, + "UserCourseProgress": 9, + "UserModuleProgress": 10, + "UserLessonProgress": 11, +} + + +def _courses_get_app_list(self, request, app_label=None): + app_list = self._courses_original_get_app_list(request, app_label) + for app in app_list: + if app.get("app_label") == "courses": + app["models"].sort( + key=lambda model_info: _COURSES_MODEL_ORDER.get( + model_info.get("object_name"), + 999, + ) + ) + return app_list + + +if not getattr(admin.site, "_courses_order_patched", False): + admin.site._courses_original_get_app_list = admin.site.get_app_list + admin.site.get_app_list = MethodType(_courses_get_app_list, admin.site) + admin.site._courses_order_patched = True + + +class OrderUniqueInlineFormSet(BaseInlineFormSet): + duplicate_field_error = "Такой порядковый номер уже используется в этом разделе." + duplicate_form_error = "Найдены дублирующиеся значения. Исправьте строки ниже." + + def get_unique_error_message(self, unique_check): + if "order" in unique_check: + return self.duplicate_field_error + return super().get_unique_error_message(unique_check) + + def get_form_error(self): + return self.duplicate_form_error + + def clean(self): + super().clean() + order_to_form = {} + has_duplicates = False + + for form in self.forms: + if not hasattr(form, "cleaned_data"): + continue + if form.cleaned_data.get("DELETE"): + continue + + order_value = form.cleaned_data.get("order") + if order_value in (None, ""): + continue + + previous_form = order_to_form.get(order_value) + if previous_form is not None: + previous_form.add_error("order", self.duplicate_field_error) + form.add_error("order", self.duplicate_field_error) + has_duplicates = True + else: + order_to_form[order_value] = form + + if has_duplicates: + raise forms.ValidationError(self.duplicate_form_error) + + +class CourseAdminForm(forms.ModelForm): + avatar_upload = forms.FileField( + required=False, + label="Аватар (загрузить файл)", + ) + card_cover_upload = forms.FileField( + required=False, + label="Обложка карточки (загрузить файл)", + ) + header_cover_upload = forms.FileField( + required=False, + label="Обложка шапки (загрузить файл)", + ) + + class Meta: + model = Course + fields = "__all__" + + +class CourseTaskAdminForm(forms.ModelForm): + image_upload = forms.FileField( + required=False, + label="Изображение (загрузить файл)", + ) + attachment_upload = forms.FileField( + required=False, + label="Файл (загрузить)", + ) + + class Meta: + model = CourseTask + fields = "__all__" + + +class CourseModuleAdminForm(forms.ModelForm): + avatar_upload = forms.FileField( + required=False, + label="Аватар (загрузить файл)", + ) + + class Meta: + model = CourseModule + fields = "__all__" + + +class CourseModuleInline(admin.TabularInline): + model = CourseModule + formset = OrderUniqueInlineFormSet + extra = 0 + fields = ("id", "title", "start_date", "status", "order") + readonly_fields = ("id",) + show_change_link = True + ordering = ("order", "id") + + +class CourseLessonInline(admin.TabularInline): + model = CourseLesson + formset = OrderUniqueInlineFormSet + extra = 0 + fields = ("id", "title", "status", "order") + readonly_fields = ("id",) + show_change_link = True + ordering = ("order", "id") + + +class CourseTaskInline(admin.TabularInline): + model = CourseTask + formset = OrderUniqueInlineFormSet + extra = 0 + fields = ("id", "title", "task_kind", "status", "order") + readonly_fields = ("id",) + show_change_link = True + ordering = ("order", "id") + + +class CourseTaskOptionInline(admin.TabularInline): + model = CourseTaskOption + formset = OrderUniqueInlineFormSet + extra = 0 + fields = ("id", "text", "is_correct", "order") + readonly_fields = ("id",) + ordering = ("order", "id") + + +class UserTaskAnswerOptionInline(admin.TabularInline): + model = UserTaskAnswerOption + extra = 0 + fields = ("id", "option") + readonly_fields = ("id",) + raw_id_fields = ("option",) + + +class UserTaskAnswerFileInline(admin.TabularInline): + model = UserTaskAnswerFile + extra = 0 + fields = ("id", "file", "file_name", "file_size", "datetime_uploaded") + readonly_fields = ("id", "file_name", "file_size", "datetime_uploaded") + raw_id_fields = ("file",) + + +@admin.register(Course) +class CourseAdmin(admin.ModelAdmin): + form = CourseAdminForm + list_display = ( + "id", + "title", + "access_type", + "status", + "is_completed", + "start_date", + "end_date", + "partner_program", + "datetime_created", + ) + list_display_links = ("id", "title") + list_filter = ("access_type", "status", "is_completed") + search_fields = ("id", "title", "partner_program__name") + raw_id_fields = ("partner_program",) + readonly_fields = ("completed_at", "datetime_created", "datetime_updated") + list_select_related = ("partner_program",) + inlines = [CourseModuleInline] + cdn = CDN(storage=SelectelSwiftStorage()) + + fieldsets = ( + ( + None, + { + "fields": ( + "title", + "description", + "access_type", + "partner_program", + "status", + "is_completed", + ) + }, + ), + ( + "Период", + { + "fields": ( + "start_date", + "end_date", + "completed_at", + ) + }, + ), + ( + "Файлы", + { + "fields": ( + "avatar_file", + "avatar_upload", + "card_cover_file", + "card_cover_upload", + "header_cover_file", + "header_cover_upload", + ) + }, + ), + ( + "Системные поля", + { + "fields": ( + "datetime_created", + "datetime_updated", + ) + }, + ), + ) + + def _create_user_file(self, request, uploaded_file): + info = self.cdn.upload(uploaded_file, request.user, quality=100) + return UserFile.objects.create( + link=info.url, + user=request.user, + name=info.name, + size=info.size, + extension=info.extension, + mime_type=info.mime_type, + ) + + def save_model(self, request, obj, form, change): + avatar_upload = form.cleaned_data.get("avatar_upload") + if avatar_upload: + obj.avatar_file = self._create_user_file(request, avatar_upload) + + card_cover_upload = form.cleaned_data.get("card_cover_upload") + if card_cover_upload: + obj.card_cover_file = self._create_user_file(request, card_cover_upload) + + header_cover_upload = form.cleaned_data.get("header_cover_upload") + if header_cover_upload: + obj.header_cover_file = self._create_user_file(request, header_cover_upload) + + super().save_model(request, obj, form, change) + + +@admin.register(CourseModule) +class CourseModuleAdmin(admin.ModelAdmin): + form = CourseModuleAdminForm + list_display = ( + "id", + "title", + "course", + "status", + "start_date", + "order", + "datetime_created", + ) + list_display_links = ("id", "title") + list_filter = ("status", "course") + search_fields = ("id", "title", "course__title") + raw_id_fields = ("course",) + readonly_fields = ("datetime_created", "datetime_updated") + list_select_related = ("course",) + inlines = [CourseLessonInline] + cdn = CDN(storage=SelectelSwiftStorage()) + + fieldsets = ( + ( + None, + { + "fields": ( + "course", + "title", + "start_date", + "status", + "order", + ) + }, + ), + ( + "Файлы", + { + "fields": ( + "avatar_file", + "avatar_upload", + ) + }, + ), + ( + "Системные поля", + { + "fields": ( + "datetime_created", + "datetime_updated", + ) + }, + ), + ) + + def _create_user_file(self, request, uploaded_file): + info = self.cdn.upload(uploaded_file, request.user, quality=100) + return UserFile.objects.create( + link=info.url, + user=request.user, + name=info.name, + size=info.size, + extension=info.extension, + mime_type=info.mime_type, + ) + + def save_model(self, request, obj, form, change): + avatar_upload = form.cleaned_data.get("avatar_upload") + if avatar_upload: + obj.avatar_file = self._create_user_file(request, avatar_upload) + super().save_model(request, obj, form, change) + + +@admin.register(CourseLesson) +class CourseLessonAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "module", + "get_course", + "status", + "order", + "datetime_created", + ) + list_display_links = ("id", "title") + list_filter = ("status", "module__course") + search_fields = ("id", "title", "module__title", "module__course__title") + raw_id_fields = ("module",) + readonly_fields = ("datetime_created", "datetime_updated") + list_select_related = ("module", "module__course") + + @admin.display(description="Курс", ordering="module__course__title") + def get_course(self, obj): + return obj.module.course + + +@admin.register(CourseTask) +class CourseTaskAdmin(admin.ModelAdmin): + form = CourseTaskAdminForm + list_display = ( + "id", + "title", + "lesson", + "get_module", + "get_course", + "task_kind", + "status", + "question_type", + "answer_type", + "order", + ) + list_display_links = ("id", "title") + list_filter = ( + "status", + "task_kind", + "check_type", + "question_type", + "answer_type", + "lesson__module__course", + ) + search_fields = ( + "id", + "title", + "lesson__title", + "lesson__module__title", + "lesson__module__course__title", + ) + raw_id_fields = ("lesson",) + readonly_fields = ("datetime_created", "datetime_updated") + list_select_related = ("lesson", "lesson__module", "lesson__module__course") + inlines = [CourseTaskOptionInline] + cdn = CDN(storage=SelectelSwiftStorage()) + + fieldsets = ( + ( + None, + { + "fields": ( + "lesson", + "title", + "status", + "task_kind", + "order", + ) + }, + ), + ( + "Типы задания", + { + "fields": ( + "check_type", + "informational_type", + "question_type", + "answer_type", + ) + }, + ), + ( + "Контент", + { + "fields": ( + "body_text", + "video_url", + "image_file", + "image_upload", + "attachment_file", + "attachment_upload", + ) + }, + ), + ( + "Системные поля", + { + "fields": ( + "datetime_created", + "datetime_updated", + ) + }, + ), + ) + + def _create_user_file(self, request, uploaded_file): + info = self.cdn.upload(uploaded_file, request.user, quality=100) + return UserFile.objects.create( + link=info.url, + user=request.user, + name=info.name, + size=info.size, + extension=info.extension, + mime_type=info.mime_type, + ) + + def save_model(self, request, obj, form, change): + image_upload = form.cleaned_data.get("image_upload") + if image_upload: + obj.image_file = self._create_user_file(request, image_upload) + + attachment_upload = form.cleaned_data.get("attachment_upload") + if attachment_upload: + obj.attachment_file = self._create_user_file(request, attachment_upload) + + super().save_model(request, obj, form, change) + + @admin.display(description="Модуль", ordering="lesson__module__title") + def get_module(self, obj): + return obj.lesson.module + + @admin.display(description="Курс", ordering="lesson__module__course__title") + def get_course(self, obj): + return obj.lesson.module.course + + +@admin.register(CourseTaskOption) +class CourseTaskOptionAdmin(admin.ModelAdmin): + list_display = ("id", "task", "text", "is_correct", "order", "datetime_created") + list_display_links = ("id", "text") + list_filter = ("is_correct", "task__answer_type") + search_fields = ("id", "text", "task__title") + raw_id_fields = ("task",) + readonly_fields = ("datetime_created", "datetime_updated") + list_select_related = ("task",) + + +@admin.register(UserTaskAnswer) +class UserTaskAnswerAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "task", + "status", + "is_correct", + "submitted_at", + "reviewed_by", + "reviewed_at", + ) + list_display_links = ("id",) + list_filter = ( + "status", + "is_correct", + "task__check_type", + "task__answer_type", + "task__lesson__module__course", + ) + search_fields = ( + "id", + "user__email", + "user__first_name", + "user__last_name", + "task__title", + ) + raw_id_fields = ("user", "task", "reviewed_by") + readonly_fields = ("submitted_at", "datetime_created", "datetime_updated") + list_select_related = ( + "user", + "task", + "reviewed_by", + "task__lesson", + "task__lesson__module", + "task__lesson__module__course", + ) + inlines = [UserTaskAnswerOptionInline, UserTaskAnswerFileInline] + + +@admin.register(UserTaskAnswerOption) +class UserTaskAnswerOptionAdmin(admin.ModelAdmin): + list_display = ("id", "answer", "option", "get_user", "get_task") + list_display_links = ("id",) + search_fields = ( + "id", + "answer__user__email", + "answer__task__title", + "option__text", + ) + raw_id_fields = ("answer", "option") + list_select_related = ("answer", "option", "answer__user", "answer__task") + + @admin.display(description="Пользователь", ordering="answer__user") + def get_user(self, obj): + return obj.answer.user + + @admin.display(description="Задание", ordering="answer__task") + def get_task(self, obj): + return obj.answer.task + + +@admin.register(UserTaskAnswerFile) +class UserTaskAnswerFileAdmin(admin.ModelAdmin): + list_display = ("id", "answer", "file", "file_name", "file_size", "datetime_uploaded") + list_display_links = ("id",) + search_fields = ("id", "file_name", "answer__task__title", "answer__user__email") + raw_id_fields = ("answer", "file") + readonly_fields = ("file_name", "file_size", "datetime_uploaded") + list_select_related = ("answer", "file", "answer__user", "answer__task") + + +@admin.register(UserCourseProgress) +class UserCourseProgressAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "course", + "status", + "percent", + "started_at", + "completed_at", + "last_visit_at", + "datetime_updated", + ) + list_display_links = ("id",) + list_filter = ("status", "course") + search_fields = ( + "id", + "user__email", + "user__first_name", + "user__last_name", + "course__title", + ) + raw_id_fields = ("user", "course") + readonly_fields = ("datetime_created", "datetime_updated") + list_select_related = ("user", "course") + + +@admin.register(UserModuleProgress) +class UserModuleProgressAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "module", + "get_course", + "status", + "percent", + "started_at", + "completed_at", + "datetime_updated", + ) + list_display_links = ("id",) + list_filter = ("status", "module__course") + search_fields = ( + "id", + "user__email", + "user__first_name", + "user__last_name", + "module__title", + "module__course__title", + ) + raw_id_fields = ("user", "module") + readonly_fields = ("datetime_created", "datetime_updated") + list_select_related = ("user", "module", "module__course") + + @admin.display(description="Курс", ordering="module__course__title") + def get_course(self, obj): + return obj.module.course + + +@admin.register(UserLessonProgress) +class UserLessonProgressAdmin(admin.ModelAdmin): + list_display = ( + "id", + "user", + "lesson", + "get_module", + "get_course", + "status", + "percent", + "current_task", + "started_at", + "completed_at", + "datetime_updated", + ) + list_display_links = ("id",) + list_filter = ("status", "lesson__module__course") + search_fields = ( + "id", + "user__email", + "user__first_name", + "user__last_name", + "lesson__title", + "lesson__module__title", + "lesson__module__course__title", + ) + raw_id_fields = ("user", "lesson", "current_task") + readonly_fields = ("datetime_created", "datetime_updated") + list_select_related = ( + "user", + "lesson", + "lesson__module", + "lesson__module__course", + "current_task", + ) + + @admin.display(description="Модуль", ordering="lesson__module__title") + def get_module(self, obj): + return obj.lesson.module + + @admin.display(description="Курс", ordering="lesson__module__course__title") + def get_course(self, obj): + return obj.lesson.module.course diff --git a/courses/apps.py b/courses/apps.py new file mode 100644 index 00000000..d3c97946 --- /dev/null +++ b/courses/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CoursesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "courses" + verbose_name = "Курсы" diff --git a/courses/migrations/0001_initial.py b/courses/migrations/0001_initial.py new file mode 100644 index 00000000..b23518d3 --- /dev/null +++ b/courses/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.11 on 2026-02-20 05:56 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('partner_programs', '0016_partnerprogram_is_distributed_evaluation'), + ('files', '0007_auto_20230929_1727'), + ] + + operations = [ + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=45, verbose_name='Название курса')), + ('description', models.TextField(blank=True, default='', validators=[django.core.validators.MaxLengthValidator(600)], verbose_name='Описание')), + ('access_type', models.CharField(choices=[('all_users', 'Для всех пользователей'), ('program_members', 'Для участников программы'), ('subscription_stub', 'По подписке')], default='all_users', max_length=32, verbose_name='Тип доступа')), + ('start_date', models.DateField(blank=True, null=True, verbose_name='Дата старта')), + ('end_date', models.DateField(blank=True, null=True, verbose_name='Дата окончания')), + ('status', models.CharField(choices=[('draft', 'Черновик'), ('published', 'Опубликован'), ('completed', 'Завершен')], default='draft', max_length=16, verbose_name='Статус курса')), + ('is_completed', models.BooleanField(default=False, verbose_name='Курс завершен')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата завершения')), + ('datetime_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('datetime_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('avatar_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='course_avatars', to='files.userfile', verbose_name='Аватар курса')), + ('card_cover_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='course_card_covers', to='files.userfile', verbose_name='Обложка карточки курса')), + ('header_cover_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='course_header_covers', to='files.userfile', verbose_name='Обложка шапки курса')), + ('partner_program', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='courses', to='partner_programs.partnerprogram', verbose_name='Программа')), + ], + options={ + 'verbose_name': 'Курс', + 'verbose_name_plural': 'Курсы', + 'ordering': ('-datetime_created',), + }, + ), + ] diff --git a/courses/migrations/0002_course_courses_program_members_requires_program_and_more.py b/courses/migrations/0002_course_courses_program_members_requires_program_and_more.py new file mode 100644 index 00000000..4c3c193f --- /dev/null +++ b/courses/migrations/0002_course_courses_program_members_requires_program_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.11 on 2026-02-20 06:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0001_initial'), + ] + + operations = [ + migrations.AddConstraint( + model_name='course', + constraint=models.CheckConstraint(check=models.Q(models.Q(('access_type', 'program_members'), _negated=True), ('partner_program__isnull', False), _connector='OR'), name='courses_program_members_requires_program'), + ), + migrations.AddConstraint( + model_name='course', + constraint=models.CheckConstraint(check=models.Q(models.Q(('end_date__isnull', True), ('start_date__isnull', True)), models.Q(('end_date__isnull', False), ('start_date__isnull', False)), _connector='OR'), name='courses_dates_must_be_set_together'), + ), + migrations.AddConstraint( + model_name='course', + constraint=models.CheckConstraint(check=models.Q(('start_date__isnull', True), ('end_date__gte', models.F('start_date')), _connector='OR'), name='courses_end_date_gte_start_date'), + ), + migrations.AddConstraint( + model_name='course', + constraint=models.CheckConstraint(check=models.Q(models.Q(('status', 'completed'), _negated=True), ('is_completed', True), _connector='OR'), name='courses_completed_status_implies_flag'), + ), + migrations.AddConstraint( + model_name='course', + constraint=models.CheckConstraint(check=models.Q(('is_completed', False), ('status', 'completed'), _connector='OR'), name='courses_completed_flag_implies_status'), + ), + ] diff --git a/courses/migrations/0003_coursemodule_and_more.py b/courses/migrations/0003_coursemodule_and_more.py new file mode 100644 index 00000000..ffe2d94f --- /dev/null +++ b/courses/migrations/0003_coursemodule_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.11 on 2026-02-20 06:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('files', '0007_auto_20230929_1727'), + ('courses', '0002_course_courses_program_members_requires_program_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='CourseModule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=40, verbose_name='Название модуля')), + ('start_date', models.DateField(verbose_name='Дата старта модуля')), + ('status', models.CharField(choices=[('draft', 'Черновик'), ('published', 'Опубликован')], default='draft', max_length=16, verbose_name='Статус модуля')), + ('order', models.PositiveIntegerField(default=1, verbose_name='Порядковый номер')), + ('datetime_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('datetime_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('avatar_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='course_module_avatars', to='files.userfile', verbose_name='Аватар модуля')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='courses.course', verbose_name='Курс')), + ], + options={ + 'verbose_name': 'Модуль курса', + 'verbose_name_plural': 'Модули курса', + 'ordering': ('course_id', 'order', 'id'), + }, + ), + migrations.AddConstraint( + model_name='coursemodule', + constraint=models.UniqueConstraint(fields=('course', 'order'), name='courses_module_unique_course_order'), + ), + migrations.AddConstraint( + model_name='coursemodule', + constraint=models.CheckConstraint(check=models.Q(('order__gte', 1)), name='courses_module_order_gte_1'), + ), + ] diff --git a/courses/migrations/0004_courselesson_and_more.py b/courses/migrations/0004_courselesson_and_more.py new file mode 100644 index 00000000..6c56006c --- /dev/null +++ b/courses/migrations/0004_courselesson_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.11 on 2026-02-20 06:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0003_coursemodule_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='CourseLesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=45, verbose_name='Название урока')), + ('status', models.CharField(choices=[('draft', 'Черновик'), ('published', 'Опубликован')], default='draft', max_length=16, verbose_name='Статус урока')), + ('order', models.PositiveIntegerField(default=1, verbose_name='Порядковый номер')), + ('datetime_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('datetime_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='courses.coursemodule', verbose_name='Модуль курса')), + ], + options={ + 'verbose_name': 'Урок курса', + 'verbose_name_plural': 'Уроки курса', + 'ordering': ('module_id', 'order', 'id'), + }, + ), + migrations.AddConstraint( + model_name='courselesson', + constraint=models.UniqueConstraint(fields=('module', 'order'), name='courses_lesson_unique_module_order'), + ), + migrations.AddConstraint( + model_name='courselesson', + constraint=models.CheckConstraint(check=models.Q(('order__gte', 1)), name='courses_lesson_order_gte_1'), + ), + ] diff --git a/courses/migrations/0005_coursetask_coursetaskoption_usertaskanswer_and_more.py b/courses/migrations/0005_coursetask_coursetaskoption_usertaskanswer_and_more.py new file mode 100644 index 00000000..7fab7be8 --- /dev/null +++ b/courses/migrations/0005_coursetask_coursetaskoption_usertaskanswer_and_more.py @@ -0,0 +1,155 @@ +# Generated by Django 4.2.11 on 2026-02-20 08:41 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('files', '0007_auto_20230929_1727'), + ('courses', '0004_courselesson_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='CourseTask', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Название задания')), + ('body_text', models.TextField(blank=True, default='', verbose_name='Текст')), + ('status', models.CharField(choices=[('draft', 'Черновик'), ('published', 'Опубликован')], default='draft', max_length=16, verbose_name='Статус задания')), + ('task_kind', models.CharField(choices=[('informational', 'Информационное'), ('question', 'Вопрос/ответ')], max_length=16, verbose_name='Тип задания')), + ('check_type', models.CharField(blank=True, choices=[('without_review', 'Без проверки'), ('with_review', 'С проверкой')], max_length=16, null=True, verbose_name='Тип проверки')), + ('informational_type', models.CharField(blank=True, choices=[('video_text', 'Видео и текст'), ('text', 'Текст'), ('text_image', 'Текст и изображение')], max_length=24, null=True, verbose_name='Тип информационного задания')), + ('question_type', models.CharField(blank=True, choices=[('image_text', 'Изображение и текст'), ('video', 'Видео'), ('image', 'Изображение'), ('text_file', 'Текст с файлом'), ('text', 'Текст')], max_length=24, null=True, verbose_name='Тип вопроса')), + ('answer_type', models.CharField(blank=True, choices=[('text', 'Текст'), ('text_and_files', 'Текст и загрузка файла'), ('files', 'Загрузка файла'), ('multiple_choice', 'Выбор одного или нескольких вариантов ответа'), ('single_choice', 'Выбор одного варианта ответа')], max_length=24, null=True, verbose_name='Тип ответа')), + ('video_url', models.URLField(blank=True, null=True, verbose_name='Ссылка на видео')), + ('order', models.PositiveIntegerField(default=1, verbose_name='Порядковый номер')), + ('datetime_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('datetime_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('attachment_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='course_task_attachments', to='files.userfile', verbose_name='Файл')), + ('image_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='course_task_images', to='files.userfile', verbose_name='Изображение')), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='courses.courselesson', verbose_name='Урок курса')), + ], + options={ + 'verbose_name': 'Задание курса', + 'verbose_name_plural': 'Задания курса', + 'ordering': ('lesson_id', 'order', 'id'), + }, + ), + migrations.CreateModel( + name='CourseTaskOption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.CharField(max_length=500, verbose_name='Текст варианта')), + ('is_correct', models.BooleanField(default=False, verbose_name='Правильный вариант')), + ('order', models.PositiveIntegerField(default=1, verbose_name='Порядковый номер')), + ('datetime_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('datetime_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='courses.coursetask', verbose_name='Задание')), + ], + options={ + 'verbose_name': 'Вариант ответа задания', + 'verbose_name_plural': 'Варианты ответов задания', + 'ordering': ('task_id', 'order', 'id'), + }, + ), + migrations.CreateModel( + name='UserTaskAnswer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('submitted', 'Отправлено'), ('pending_review', 'Ожидает проверки'), ('accepted', 'Принято'), ('rejected', 'Отклонено')], default='submitted', max_length=20, verbose_name='Статус ответа')), + ('answer_text', models.CharField(blank=True, default='', max_length=1000, verbose_name='Текст ответа')), + ('is_correct', models.BooleanField(blank=True, null=True, verbose_name='Ответ корректен')), + ('submitted_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата и время отправки')), + ('review_comment', models.TextField(blank=True, default='', verbose_name='Комментарий проверяющего')), + ('reviewed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата и время проверки')), + ('datetime_created', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('datetime_updated', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_course_task_answers', to=settings.AUTH_USER_MODEL, verbose_name='Проверил')), + ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_answers', to='courses.coursetask', verbose_name='Задание')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_task_answers', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Ответ пользователя на задание', + 'verbose_name_plural': 'Ответы пользователей на задания', + 'ordering': ('-submitted_at', 'id'), + }, + ), + migrations.CreateModel( + name='UserTaskAnswerOption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('answer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='selected_options', to='courses.usertaskanswer', verbose_name='Ответ пользователя')), + ('option', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='selected_in_answers', to='courses.coursetaskoption', verbose_name='Выбранный вариант')), + ], + options={ + 'verbose_name': 'Выбранный вариант ответа', + 'verbose_name_plural': 'Выбранные варианты ответа', + }, + ), + migrations.CreateModel( + name='UserTaskAnswerFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_name', models.TextField(blank=True, default='', verbose_name='Название файла')), + ('file_size', models.PositiveBigIntegerField(default=0, verbose_name='Размер файла')), + ('datetime_uploaded', models.DateTimeField(auto_now_add=True, verbose_name='Дата загрузки')), + ('answer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='courses.usertaskanswer', verbose_name='Ответ пользователя')), + ('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_task_answer_files', to='files.userfile', verbose_name='Файл')), + ], + options={ + 'verbose_name': 'Файл ответа пользователя', + 'verbose_name_plural': 'Файлы ответов пользователей', + 'ordering': ('-datetime_uploaded', 'id'), + }, + ), + migrations.AddConstraint( + model_name='usertaskansweroption', + constraint=models.UniqueConstraint(fields=('answer', 'option'), name='courses_user_task_answer_option_unique_answer_option'), + ), + migrations.AddConstraint( + model_name='usertaskanswerfile', + constraint=models.UniqueConstraint(fields=('answer', 'file'), name='courses_user_task_answer_file_unique_answer_file'), + ), + migrations.AddConstraint( + model_name='usertaskanswer', + constraint=models.UniqueConstraint(fields=('user', 'task'), name='courses_user_task_answer_unique_user_task'), + ), + migrations.AddConstraint( + model_name='coursetaskoption', + constraint=models.UniqueConstraint(fields=('task', 'order'), name='courses_task_option_unique_task_order'), + ), + migrations.AddConstraint( + model_name='coursetaskoption', + constraint=models.CheckConstraint(check=models.Q(('order__gte', 1)), name='courses_task_option_order_gte_1'), + ), + migrations.AddConstraint( + model_name='coursetask', + constraint=models.UniqueConstraint(fields=('lesson', 'order'), name='courses_task_unique_lesson_order'), + ), + migrations.AddConstraint( + model_name='coursetask', + constraint=models.CheckConstraint(check=models.Q(('order__gte', 1)), name='courses_task_order_gte_1'), + ), + migrations.AddConstraint( + model_name='coursetask', + constraint=models.CheckConstraint(check=models.Q(models.Q(('task_kind', 'informational'), _negated=True), ('informational_type__isnull', False), _connector='OR'), name='courses_task_info_requires_info_type'), + ), + migrations.AddConstraint( + model_name='coursetask', + constraint=models.CheckConstraint(check=models.Q(models.Q(('task_kind', 'question'), _negated=True), models.Q(('question_type__isnull', False), ('answer_type__isnull', False), ('check_type__isnull', False)), _connector='OR'), name='courses_task_question_requires_types'), + ), + migrations.AddConstraint( + model_name='coursetask', + constraint=models.CheckConstraint(check=models.Q(models.Q(('task_kind', 'informational'), _negated=True), models.Q(('question_type__isnull', True), ('answer_type__isnull', True), ('check_type__isnull', True)), _connector='OR'), name='courses_task_info_forbids_question_fields'), + ), + migrations.AddConstraint( + model_name='coursetask', + constraint=models.CheckConstraint(check=models.Q(models.Q(('task_kind', 'question'), _negated=True), ('informational_type__isnull', True), _connector='OR'), name='courses_task_question_forbids_info_type'), + ), + ] diff --git a/courses/migrations/0006_usercourseprogress_userlessonprogress_and_more.py b/courses/migrations/0006_usercourseprogress_userlessonprogress_and_more.py new file mode 100644 index 00000000..45fbe888 --- /dev/null +++ b/courses/migrations/0006_usercourseprogress_userlessonprogress_and_more.py @@ -0,0 +1,363 @@ +# Generated by Django 4.2.11 on 2026-03-03 12:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("courses", "0005_coursetask_coursetaskoption_usertaskanswer_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="UserCourseProgress", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("not_started", "Не начато"), + ("in_progress", "В процессе"), + ("completed", "Завершено"), + ("blocked", "Заблокировано"), + ], + default="not_started", + max_length=16, + verbose_name="Статус прогресса", + ), + ), + ( + "percent", + models.PositiveSmallIntegerField(default=0, verbose_name="Прогресс, %"), + ), + ( + "started_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Дата начала" + ), + ), + ( + "completed_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Дата завершения" + ), + ), + ( + "last_visit_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Дата последнего визита" + ), + ), + ( + "datetime_created", + models.DateTimeField(auto_now_add=True, verbose_name="Дата создания"), + ), + ( + "datetime_updated", + models.DateTimeField(auto_now=True, verbose_name="Дата обновления"), + ), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_progresses", + to="courses.course", + verbose_name="Курс", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="course_progresses", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ], + options={ + "verbose_name": "Прогресс пользователя по курсу", + "verbose_name_plural": "Прогресс пользователей по курсам", + "ordering": ("-datetime_updated", "id"), + }, + ), + migrations.CreateModel( + name="UserLessonProgress", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("not_started", "Не начато"), + ("in_progress", "В процессе"), + ("completed", "Завершено"), + ("blocked", "Заблокировано"), + ], + default="not_started", + max_length=16, + verbose_name="Статус прогресса", + ), + ), + ( + "percent", + models.PositiveSmallIntegerField(default=0, verbose_name="Прогресс, %"), + ), + ( + "started_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Дата начала" + ), + ), + ( + "completed_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Дата завершения" + ), + ), + ( + "datetime_created", + models.DateTimeField(auto_now_add=True, verbose_name="Дата создания"), + ), + ( + "datetime_updated", + models.DateTimeField(auto_now=True, verbose_name="Дата обновления"), + ), + ( + "current_task", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="current_in_lesson_progress", + to="courses.coursetask", + verbose_name="Текущее задание", + ), + ), + ( + "lesson", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_progresses", + to="courses.courselesson", + verbose_name="Урок", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lesson_progresses", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ], + options={ + "verbose_name": "Прогресс пользователя по уроку", + "verbose_name_plural": "Прогресс пользователей по урокам", + "ordering": ("-datetime_updated", "id"), + }, + ), + migrations.CreateModel( + name="UserModuleProgress", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("not_started", "Не начато"), + ("in_progress", "В процессе"), + ("completed", "Завершено"), + ("blocked", "Заблокировано"), + ], + default="not_started", + max_length=16, + verbose_name="Статус прогресса", + ), + ), + ( + "percent", + models.PositiveSmallIntegerField(default=0, verbose_name="Прогресс, %"), + ), + ( + "started_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Дата начала" + ), + ), + ( + "completed_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Дата завершения" + ), + ), + ( + "datetime_created", + models.DateTimeField(auto_now_add=True, verbose_name="Дата создания"), + ), + ( + "datetime_updated", + models.DateTimeField(auto_now=True, verbose_name="Дата обновления"), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_progresses", + to="courses.coursemodule", + verbose_name="Модуль", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="module_progresses", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ], + options={ + "verbose_name": "Прогресс пользователя по модулю", + "verbose_name_plural": "Прогресс пользователей по модулям", + "ordering": ("-datetime_updated", "id"), + }, + ), + migrations.AddConstraint( + model_name="usercourseprogress", + constraint=models.UniqueConstraint( + fields=("user", "course"), + name="courses_user_course_progress_unique_user_course", + ), + ), + migrations.AddConstraint( + model_name="usercourseprogress", + constraint=models.CheckConstraint( + check=models.Q(("percent__gte", 0), ("percent__lte", 100)), + name="courses_user_course_progress_percent_0_100", + ), + ), + migrations.AddConstraint( + model_name="usercourseprogress", + constraint=models.CheckConstraint( + check=models.Q(("status", "blocked"), _negated=True), + name="courses_user_course_progress_status_no_blocked", + ), + ), + migrations.AddConstraint( + model_name="usercourseprogress", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("percent", 0), ("status", "not_started")), + models.Q( + ("percent__gte", 1), + ("percent__lte", 99), + ("status", "in_progress"), + ), + models.Q(("percent", 100), ("status", "completed")), + _connector="OR", + ), + name="courses_user_course_progress_status_percent_valid", + ), + ), + migrations.AddConstraint( + model_name="userlessonprogress", + constraint=models.UniqueConstraint( + fields=("user", "lesson"), + name="courses_user_lesson_progress_unique_user_lesson", + ), + ), + migrations.AddConstraint( + model_name="userlessonprogress", + constraint=models.CheckConstraint( + check=models.Q(("percent__gte", 0), ("percent__lte", 100)), + name="courses_user_lesson_progress_percent_0_100", + ), + ), + migrations.AddConstraint( + model_name="userlessonprogress", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("percent", 0), ("status", "not_started")), + models.Q( + ("percent__gte", 1), + ("percent__lte", 99), + ("status", "in_progress"), + ), + models.Q(("percent", 100), ("status", "completed")), + models.Q(("percent", 0), ("status", "blocked")), + _connector="OR", + ), + name="courses_user_lesson_progress_status_percent_valid", + ), + ), + migrations.AddConstraint( + model_name="usermoduleprogress", + constraint=models.UniqueConstraint( + fields=("user", "module"), + name="courses_user_module_progress_unique_user_module", + ), + ), + migrations.AddConstraint( + model_name="usermoduleprogress", + constraint=models.CheckConstraint( + check=models.Q(("percent__gte", 0), ("percent__lte", 100)), + name="courses_user_module_progress_percent_0_100", + ), + ), + migrations.AddConstraint( + model_name="usermoduleprogress", + constraint=models.CheckConstraint( + check=models.Q(("status", "blocked"), _negated=True), + name="courses_user_module_progress_status_no_blocked", + ), + ), + migrations.AddConstraint( + model_name="usermoduleprogress", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("percent", 0), ("status", "not_started")), + models.Q( + ("percent__gte", 1), + ("percent__lte", 99), + ("status", "in_progress"), + ), + models.Q(("percent", 100), ("status", "completed")), + _connector="OR", + ), + name="courses_user_module_progress_status_percent_valid", + ), + ), + ] diff --git a/courses/migrations/__init__.py b/courses/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/courses/models/__init__.py b/courses/models/__init__.py new file mode 100644 index 00000000..0f14c65e --- /dev/null +++ b/courses/models/__init__.py @@ -0,0 +1,46 @@ +from .answers import UserTaskAnswer, UserTaskAnswerFile, UserTaskAnswerOption +from .choices import ( + CourseAccessType, + CourseContentStatus, + CourseLessonContentStatus, + CourseModuleContentStatus, + CourseTaskAnswerType, + CourseTaskCheckType, + CourseTaskContentStatus, + CourseTaskInformationalType, + CourseTaskKind, + CourseTaskQuestionType, + ProgressStatus, + UserTaskAnswerStatus, +) +from .constants import DEFAULT_MAX_FILES_PER_ANSWER +from .content import CourseLesson, CourseModule, CourseTask, CourseTaskOption +from .course import Course +from .progress import UserCourseProgress, UserLessonProgress, UserModuleProgress + +__all__ = [ + "Course", + "CourseModule", + "CourseLesson", + "CourseTask", + "CourseTaskOption", + "UserCourseProgress", + "UserModuleProgress", + "UserLessonProgress", + "UserTaskAnswer", + "UserTaskAnswerOption", + "UserTaskAnswerFile", + "CourseAccessType", + "CourseContentStatus", + "CourseModuleContentStatus", + "CourseLessonContentStatus", + "CourseTaskContentStatus", + "CourseTaskKind", + "CourseTaskCheckType", + "CourseTaskInformationalType", + "CourseTaskQuestionType", + "CourseTaskAnswerType", + "ProgressStatus", + "UserTaskAnswerStatus", + "DEFAULT_MAX_FILES_PER_ANSWER", +] diff --git a/courses/models/answers.py b/courses/models/answers.py new file mode 100644 index 00000000..9579f1ab --- /dev/null +++ b/courses/models/answers.py @@ -0,0 +1,293 @@ +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models, transaction +from django.utils import timezone + +from files.models import UserFile + +from .choices import ( + CourseTaskAnswerType, + CourseTaskCheckType, + CourseTaskKind, + UserTaskAnswerStatus, +) +from .constants import DEFAULT_MAX_FILES_PER_ANSWER +from .content import CourseTask, CourseTaskOption + + +class UserTaskAnswer(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="course_task_answers", + verbose_name="Пользователь", + ) + task = models.ForeignKey( + CourseTask, + on_delete=models.CASCADE, + related_name="user_answers", + verbose_name="Задание", + ) + status = models.CharField( + max_length=20, + choices=UserTaskAnswerStatus.choices, + default=UserTaskAnswerStatus.SUBMITTED, + verbose_name="Статус ответа", + ) + answer_text = models.CharField( + max_length=1000, + blank=True, + default="", + verbose_name="Текст ответа", + ) + is_correct = models.BooleanField( + null=True, + blank=True, + verbose_name="Ответ корректен", + ) + submitted_at = models.DateTimeField( + default=timezone.now, + verbose_name="Дата и время отправки", + ) + review_comment = models.TextField( + blank=True, + default="", + verbose_name="Комментарий проверяющего", + ) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="reviewed_course_task_answers", + verbose_name="Проверил", + ) + reviewed_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата и время проверки", + ) + datetime_created = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + ) + datetime_updated = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + class Meta: + verbose_name = "Ответ пользователя на задание" + verbose_name_plural = "Ответы пользователей на задания" + ordering = ("-submitted_at", "id") + constraints = [ + models.UniqueConstraint( + fields=("user", "task"), + name="courses_user_task_answer_unique_user_task", + ), + ] + + def __str__(self): + return f"UserTaskAnswer<{self.id}> - user={self.user_id} task={self.task_id}" + + def clean(self): + super().clean() + errors = {} + if self.task_id is None: + return + + if self.task.task_kind != CourseTaskKind.QUESTION: + errors["task"] = "Ответ можно отправлять только на вопросное задание." + + answer_type = self.task.answer_type + is_text_filled = CourseTask._require_non_blank(self.answer_text) + if answer_type in ( + CourseTaskAnswerType.TEXT, + CourseTaskAnswerType.TEXT_AND_FILES, + ) and not is_text_filled: + errors["answer_text"] = "Для выбранного типа ответа требуется заполнить текст." + + if answer_type in ( + CourseTaskAnswerType.SINGLE_CHOICE, + CourseTaskAnswerType.MULTIPLE_CHOICE, + CourseTaskAnswerType.FILES, + ) and is_text_filled: + errors["answer_text"] = "Для выбранного типа ответа текст не используется." + + if ( + self.task.check_type == CourseTaskCheckType.WITHOUT_REVIEW + and self.status == UserTaskAnswerStatus.PENDING_REVIEW + ): + errors["status"] = "Для заданий без проверки статус pending_review недопустим." + + if bool(self.reviewed_by_id) != bool(self.reviewed_at): + errors["reviewed_by"] = "Поля reviewed_by и reviewed_at должны быть заполнены вместе." + errors["reviewed_at"] = "Поля reviewed_by и reviewed_at должны быть заполнены вместе." + + if ( + self.task.check_type == CourseTaskCheckType.WITH_REVIEW + and self.status in (UserTaskAnswerStatus.ACCEPTED, UserTaskAnswerStatus.REJECTED) + and not self.reviewed_by_id + ): + errors["reviewed_by"] = "Для проверенного ответа укажите проверяющего." + errors["reviewed_at"] = "Для проверенного ответа укажите время проверки." + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + validate = kwargs.pop("validate", True) + if validate: + self.full_clean() + super().save(*args, **kwargs) + + +class UserTaskAnswerOption(models.Model): + answer = models.ForeignKey( + UserTaskAnswer, + on_delete=models.CASCADE, + related_name="selected_options", + verbose_name="Ответ пользователя", + ) + option = models.ForeignKey( + CourseTaskOption, + on_delete=models.CASCADE, + related_name="selected_in_answers", + verbose_name="Выбранный вариант", + ) + + class Meta: + verbose_name = "Выбранный вариант ответа" + verbose_name_plural = "Выбранные варианты ответа" + constraints = [ + models.UniqueConstraint( + fields=("answer", "option"), + name="courses_user_task_answer_option_unique_answer_option", + ), + ] + + def __str__(self): + return ( + f"UserTaskAnswerOption<{self.id}> " + f"- answer={self.answer_id} option={self.option_id}" + ) + + def clean(self): + super().clean() + errors = {} + if self.answer_id is None or self.option_id is None: + return + + if self.answer.task_id != self.option.task_id: + errors["option"] = ( + "Выбранный вариант должен относиться к тому же заданию, " + "что и ответ пользователя." + ) + + if self.answer.task.answer_type not in ( + CourseTaskAnswerType.SINGLE_CHOICE, + CourseTaskAnswerType.MULTIPLE_CHOICE, + ): + errors["answer"] = ( + "Выбор варианта доступен только для типов ответа " + "'single_choice' и 'multiple_choice'." + ) + + if ( + self.answer.task.answer_type == CourseTaskAnswerType.SINGLE_CHOICE + and UserTaskAnswerOption.objects.filter(answer_id=self.answer_id) + .exclude(pk=self.pk) + .exists() + ): + errors["answer"] = "Для single_choice можно выбрать только один вариант." + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + with transaction.atomic(): + if self.answer_id: + # Serializes single_choice writes for one answer. + self.answer.__class__.objects.select_for_update().get(pk=self.answer_id) + self.full_clean() + super().save(*args, **kwargs) + + +class UserTaskAnswerFile(models.Model): + answer = models.ForeignKey( + UserTaskAnswer, + on_delete=models.CASCADE, + related_name="files", + verbose_name="Ответ пользователя", + ) + file = models.ForeignKey( + UserFile, + on_delete=models.CASCADE, + related_name="course_task_answer_files", + verbose_name="Файл", + ) + file_name = models.TextField( + blank=True, + default="", + verbose_name="Название файла", + ) + file_size = models.PositiveBigIntegerField( + default=0, + verbose_name="Размер файла", + ) + datetime_uploaded = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата загрузки", + ) + + class Meta: + verbose_name = "Файл ответа пользователя" + verbose_name_plural = "Файлы ответов пользователей" + ordering = ("-datetime_uploaded", "id") + constraints = [ + models.UniqueConstraint( + fields=("answer", "file"), + name="courses_user_task_answer_file_unique_answer_file", + ), + ] + + def __str__(self): + return f"UserTaskAnswerFile<{self.id}> - answer={self.answer_id}" + + def clean(self): + super().clean() + errors = {} + if self.answer_id is None: + return + + answer_type = self.answer.task.answer_type + if answer_type not in ( + CourseTaskAnswerType.FILES, + CourseTaskAnswerType.TEXT_AND_FILES, + ): + errors["answer"] = ( + "Загрузка файлов доступна только для типов ответа " + "'files' и 'text_and_files'." + ) + + existing_count = ( + UserTaskAnswerFile.objects.filter(answer_id=self.answer_id) + .exclude(pk=self.pk) + .count() + ) + if existing_count >= DEFAULT_MAX_FILES_PER_ANSWER: + errors["answer"] = ( + f"Превышен лимит файлов в ответе ({DEFAULT_MAX_FILES_PER_ANSWER})." + ) + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + if self.file_id: + if not self.file_name: + self.file_name = self.file.name + if self.file_size == 0: + self.file_size = self.file.size + self.full_clean() + super().save(*args, **kwargs) diff --git a/courses/models/choices.py b/courses/models/choices.py new file mode 100644 index 00000000..07918de0 --- /dev/null +++ b/courses/models/choices.py @@ -0,0 +1,77 @@ +from django.db import models + + +class CourseAccessType(models.TextChoices): + ALL_USERS = "all_users", "Для всех пользователей" + PROGRAM_MEMBERS = "program_members", "Для участников программы" + SUBSCRIPTION_STUB = "subscription_stub", "По подписке" + + +class CourseContentStatus(models.TextChoices): + DRAFT = "draft", "Черновик" + PUBLISHED = "published", "Опубликован" + COMPLETED = "completed", "Завершен" + + +class CourseModuleContentStatus(models.TextChoices): + DRAFT = "draft", "Черновик" + PUBLISHED = "published", "Опубликован" + + +class CourseLessonContentStatus(models.TextChoices): + DRAFT = "draft", "Черновик" + PUBLISHED = "published", "Опубликован" + + +class CourseTaskContentStatus(models.TextChoices): + DRAFT = "draft", "Черновик" + PUBLISHED = "published", "Опубликован" + + +class CourseTaskKind(models.TextChoices): + INFORMATIONAL = "informational", "Информационное" + QUESTION = "question", "Вопрос/ответ" + + +class CourseTaskCheckType(models.TextChoices): + WITHOUT_REVIEW = "without_review", "Без проверки" + WITH_REVIEW = "with_review", "С проверкой" + + +class CourseTaskInformationalType(models.TextChoices): + VIDEO_TEXT = "video_text", "Видео и текст" + TEXT = "text", "Текст" + TEXT_IMAGE = "text_image", "Текст и изображение" + + +class CourseTaskQuestionType(models.TextChoices): + IMAGE_TEXT = "image_text", "Изображение и текст" + VIDEO = "video", "Видео" + IMAGE = "image", "Изображение" + TEXT_FILE = "text_file", "Текст с файлом" + TEXT = "text", "Текст" + + +class CourseTaskAnswerType(models.TextChoices): + TEXT = "text", "Текст" + TEXT_AND_FILES = "text_and_files", "Текст и загрузка файла" + FILES = "files", "Загрузка файла" + MULTIPLE_CHOICE = ( + "multiple_choice", + "Выбор одного или нескольких вариантов ответа", + ) + SINGLE_CHOICE = "single_choice", "Выбор одного варианта ответа" + + +class UserTaskAnswerStatus(models.TextChoices): + SUBMITTED = "submitted", "Отправлено" + PENDING_REVIEW = "pending_review", "Ожидает проверки" + ACCEPTED = "accepted", "Принято" + REJECTED = "rejected", "Отклонено" + + +class ProgressStatus(models.TextChoices): + NOT_STARTED = "not_started", "Не начато" + IN_PROGRESS = "in_progress", "В процессе" + COMPLETED = "completed", "Завершено" + BLOCKED = "blocked", "Заблокировано" diff --git a/courses/models/constants.py b/courses/models/constants.py new file mode 100644 index 00000000..580e560c --- /dev/null +++ b/courses/models/constants.py @@ -0,0 +1 @@ +DEFAULT_MAX_FILES_PER_ANSWER = 10 diff --git a/courses/models/content.py b/courses/models/content.py new file mode 100644 index 00000000..ddbc37a6 --- /dev/null +++ b/courses/models/content.py @@ -0,0 +1,448 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q + +from files.models import UserFile + +from .choices import ( + CourseLessonContentStatus, + CourseModuleContentStatus, + CourseTaskAnswerType, + CourseTaskCheckType, + CourseTaskContentStatus, + CourseTaskInformationalType, + CourseTaskKind, + CourseTaskQuestionType, +) +from .course import Course + + +class CourseModule(models.Model): + course = models.ForeignKey( + Course, + on_delete=models.CASCADE, + related_name="modules", + verbose_name="Курс", + ) + title = models.CharField( + max_length=40, + verbose_name="Название модуля", + ) + avatar_file = models.ForeignKey( + UserFile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="course_module_avatars", + verbose_name="Аватар модуля", + ) + start_date = models.DateField( + verbose_name="Дата старта модуля", + ) + status = models.CharField( + max_length=16, + choices=CourseModuleContentStatus.choices, + default=CourseModuleContentStatus.DRAFT, + verbose_name="Статус модуля", + ) + order = models.PositiveIntegerField( + default=1, + verbose_name="Порядковый номер", + ) + datetime_created = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + ) + datetime_updated = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + class Meta: + verbose_name = "Модуль курса" + verbose_name_plural = "Модули курса" + ordering = ("course_id", "order", "id") + constraints = [ + models.UniqueConstraint( + fields=("course", "order"), + name="courses_module_unique_course_order", + ), + models.CheckConstraint( + check=Q(order__gte=1), + name="courses_module_order_gte_1", + ), + ] + + def __str__(self): + return f"CourseModule<{self.id}> - {self.title}" + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + +class CourseLesson(models.Model): + module = models.ForeignKey( + CourseModule, + on_delete=models.CASCADE, + related_name="lessons", + verbose_name="Модуль курса", + ) + title = models.CharField( + max_length=45, + verbose_name="Название урока", + ) + status = models.CharField( + max_length=16, + choices=CourseLessonContentStatus.choices, + default=CourseLessonContentStatus.DRAFT, + verbose_name="Статус урока", + ) + order = models.PositiveIntegerField( + default=1, + verbose_name="Порядковый номер", + ) + datetime_created = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + ) + datetime_updated = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + class Meta: + verbose_name = "Урок курса" + verbose_name_plural = "Уроки курса" + ordering = ("module_id", "order", "id") + constraints = [ + models.UniqueConstraint( + fields=("module", "order"), + name="courses_lesson_unique_module_order", + ), + models.CheckConstraint( + check=Q(order__gte=1), + name="courses_lesson_order_gte_1", + ), + ] + + def __str__(self): + return f"CourseLesson<{self.id}> - {self.title}" + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + +class CourseTask(models.Model): + lesson = models.ForeignKey( + CourseLesson, + on_delete=models.CASCADE, + related_name="tasks", + verbose_name="Урок курса", + ) + title = models.CharField( + max_length=255, + verbose_name="Название задания", + ) + body_text = models.TextField( + blank=True, + default="", + verbose_name="Текст", + ) + status = models.CharField( + max_length=16, + choices=CourseTaskContentStatus.choices, + default=CourseTaskContentStatus.DRAFT, + verbose_name="Статус задания", + ) + task_kind = models.CharField( + max_length=16, + choices=CourseTaskKind.choices, + verbose_name="Тип задания", + ) + check_type = models.CharField( + max_length=16, + choices=CourseTaskCheckType.choices, + null=True, + blank=True, + verbose_name="Тип проверки", + ) + informational_type = models.CharField( + max_length=24, + choices=CourseTaskInformationalType.choices, + null=True, + blank=True, + verbose_name="Тип информационного задания", + ) + question_type = models.CharField( + max_length=24, + choices=CourseTaskQuestionType.choices, + null=True, + blank=True, + verbose_name="Тип вопроса", + ) + answer_type = models.CharField( + max_length=24, + choices=CourseTaskAnswerType.choices, + null=True, + blank=True, + verbose_name="Тип ответа", + ) + video_url = models.URLField( + null=True, + blank=True, + verbose_name="Ссылка на видео", + ) + image_file = models.ForeignKey( + UserFile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="course_task_images", + verbose_name="Изображение", + ) + attachment_file = models.ForeignKey( + UserFile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="course_task_attachments", + verbose_name="Файл", + ) + order = models.PositiveIntegerField( + default=1, + verbose_name="Порядковый номер", + ) + datetime_created = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + ) + datetime_updated = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + class Meta: + verbose_name = "Задание курса" + verbose_name_plural = "Задания курса" + ordering = ("lesson_id", "order", "id") + constraints = [ + models.UniqueConstraint( + fields=("lesson", "order"), + name="courses_task_unique_lesson_order", + ), + models.CheckConstraint( + check=Q(order__gte=1), + name="courses_task_order_gte_1", + ), + models.CheckConstraint( + check=~Q(task_kind=CourseTaskKind.INFORMATIONAL) + | Q(informational_type__isnull=False), + name="courses_task_info_requires_info_type", + ), + models.CheckConstraint( + check=~Q(task_kind=CourseTaskKind.QUESTION) + | ( + Q(question_type__isnull=False) + & Q(answer_type__isnull=False) + & Q(check_type__isnull=False) + ), + name="courses_task_question_requires_types", + ), + models.CheckConstraint( + check=~Q(task_kind=CourseTaskKind.INFORMATIONAL) + | ( + Q(question_type__isnull=True) + & Q(answer_type__isnull=True) + & Q(check_type__isnull=True) + ), + name="courses_task_info_forbids_question_fields", + ), + models.CheckConstraint( + check=~Q(task_kind=CourseTaskKind.QUESTION) + | Q(informational_type__isnull=True), + name="courses_task_question_forbids_info_type", + ), + ] + + def __str__(self): + return f"CourseTask<{self.id}> - {self.title}" + + @staticmethod + def _require_non_blank(value: str | None) -> bool: + return bool(value and value.strip()) + + def clean(self): + super().clean() + + errors = {} + + if self.task_kind == CourseTaskKind.INFORMATIONAL: + if self.informational_type == CourseTaskInformationalType.VIDEO_TEXT: + if not self._require_non_blank(self.body_text): + errors["body_text"] = "Поле обязательно для типа 'Видео и текст'." + if not self._require_non_blank(self.video_url): + errors["video_url"] = "Поле обязательно для типа 'Видео и текст'." + elif self.informational_type == CourseTaskInformationalType.TEXT: + if not self._require_non_blank(self.body_text): + errors["body_text"] = "Поле обязательно для типа 'Текст'." + elif self.informational_type == CourseTaskInformationalType.TEXT_IMAGE: + if not self._require_non_blank(self.body_text): + errors["body_text"] = ( + "Поле обязательно для типа 'Текст и изображение'." + ) + if self.image_file_id is None: + errors["image_file"] = ( + "Поле обязательно для типа 'Текст и изображение'." + ) + + if self.task_kind == CourseTaskKind.QUESTION: + if self.question_type == CourseTaskQuestionType.IMAGE_TEXT: + if not self._require_non_blank(self.body_text): + errors["body_text"] = ( + "Поле обязательно для типа вопроса 'Изображение и текст'." + ) + if self.image_file_id is None: + errors["image_file"] = ( + "Поле обязательно для типа вопроса 'Изображение и текст'." + ) + elif self.question_type == CourseTaskQuestionType.VIDEO: + if not self._require_non_blank(self.video_url): + errors["video_url"] = "Поле обязательно для типа вопроса 'Видео'." + elif self.question_type == CourseTaskQuestionType.IMAGE: + if self.image_file_id is None: + errors["image_file"] = ( + "Поле обязательно для типа вопроса 'Изображение'." + ) + elif self.question_type == CourseTaskQuestionType.TEXT_FILE: + if not self._require_non_blank(self.body_text): + errors["body_text"] = ( + "Поле обязательно для типа вопроса 'Текст с файлом'." + ) + if self.attachment_file_id is None: + errors["attachment_file"] = ( + "Поле обязательно для типа вопроса 'Текст с файлом'." + ) + elif self.question_type == CourseTaskQuestionType.TEXT: + if not self._require_non_blank(self.body_text): + errors["body_text"] = "Поле обязательно для типа вопроса 'Текст'." + + if ( + self.status == CourseTaskContentStatus.PUBLISHED + and self.task_kind == CourseTaskKind.QUESTION + and self.answer_type + in ( + CourseTaskAnswerType.SINGLE_CHOICE, + CourseTaskAnswerType.MULTIPLE_CHOICE, + ) + ): + correct_count = ( + self.options.filter(is_correct=True).count() + if self.pk + else 0 + ) + if correct_count == 0: + errors["status"] = ( + "Нельзя опубликовать тестовое задание без правильного варианта." + ) + elif ( + self.answer_type == CourseTaskAnswerType.SINGLE_CHOICE + and correct_count != 1 + ): + errors["status"] = ( + "Для single_choice должен быть ровно один правильный вариант." + ) + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + +class CourseTaskOption(models.Model): + task = models.ForeignKey( + CourseTask, + on_delete=models.CASCADE, + related_name="options", + verbose_name="Задание", + ) + text = models.CharField( + max_length=500, + verbose_name="Текст варианта", + ) + is_correct = models.BooleanField( + default=False, + verbose_name="Правильный вариант", + ) + order = models.PositiveIntegerField( + default=1, + verbose_name="Порядковый номер", + ) + datetime_created = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + ) + datetime_updated = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + class Meta: + verbose_name = "Вариант ответа задания" + verbose_name_plural = "Варианты ответов задания" + ordering = ("task_id", "order", "id") + constraints = [ + models.UniqueConstraint( + fields=("task", "order"), + name="courses_task_option_unique_task_order", + ), + models.CheckConstraint( + check=Q(order__gte=1), + name="courses_task_option_order_gte_1", + ), + ] + + def __str__(self): + return f"CourseTaskOption<{self.id}> - task={self.task_id}" + + def clean(self): + super().clean() + errors = {} + if self.task_id is None: + return + + if self.task.task_kind != CourseTaskKind.QUESTION: + errors["task"] = "Варианты ответа допустимы только для вопросных заданий." + + if self.task.answer_type not in ( + CourseTaskAnswerType.SINGLE_CHOICE, + CourseTaskAnswerType.MULTIPLE_CHOICE, + ): + errors["task"] = ( + "Варианты ответа доступны только для типов " + "'single_choice' и 'multiple_choice'." + ) + + if ( + self.task.answer_type == CourseTaskAnswerType.SINGLE_CHOICE + and self.is_correct + and CourseTaskOption.objects.filter( + task_id=self.task_id, + is_correct=True, + ) + .exclude(pk=self.pk) + .exists() + ): + errors["is_correct"] = ( + "Для single_choice может быть только один правильный вариант." + ) + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) diff --git a/courses/models/course.py b/courses/models/course.py new file mode 100644 index 00000000..5c2f0cd8 --- /dev/null +++ b/courses/models/course.py @@ -0,0 +1,185 @@ +from django.core.exceptions import ValidationError +from django.core.validators import MaxLengthValidator +from django.db import models +from django.db.models import F, Q +from django.utils import timezone + +from files.models import UserFile +from partner_programs.models import PartnerProgram + +from .choices import CourseAccessType, CourseContentStatus + + +class Course(models.Model): + title = models.CharField( + max_length=45, + verbose_name="Название курса", + ) + description = models.TextField( + blank=True, + default="", + validators=[MaxLengthValidator(600)], + verbose_name="Описание", + ) + access_type = models.CharField( + max_length=32, + choices=CourseAccessType.choices, + default=CourseAccessType.ALL_USERS, + verbose_name="Тип доступа", + ) + partner_program = models.ForeignKey( + PartnerProgram, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="courses", + verbose_name="Программа", + ) + avatar_file = models.ForeignKey( + UserFile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="course_avatars", + verbose_name="Аватар курса", + ) + card_cover_file = models.ForeignKey( + UserFile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="course_card_covers", + verbose_name="Обложка карточки курса", + ) + header_cover_file = models.ForeignKey( + UserFile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="course_header_covers", + verbose_name="Обложка шапки курса", + ) + start_date = models.DateField( + null=True, + blank=True, + verbose_name="Дата старта", + ) + end_date = models.DateField( + null=True, + blank=True, + verbose_name="Дата окончания", + ) + status = models.CharField( + max_length=16, + choices=CourseContentStatus.choices, + default=CourseContentStatus.DRAFT, + verbose_name="Статус курса", + ) + is_completed = models.BooleanField( + default=False, + verbose_name="Курс завершен", + ) + completed_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата завершения", + ) + datetime_created = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + ) + datetime_updated = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + class Meta: + verbose_name = "Курс" + verbose_name_plural = "Курсы" + ordering = ("-datetime_created",) + constraints = [ + models.CheckConstraint( + check=~Q(access_type=CourseAccessType.PROGRAM_MEMBERS) + | Q(partner_program__isnull=False), + name="courses_program_members_requires_program", + ), + models.CheckConstraint( + check=( + Q(start_date__isnull=True, end_date__isnull=True) + | Q(start_date__isnull=False, end_date__isnull=False) + ), + name="courses_dates_must_be_set_together", + ), + models.CheckConstraint( + check=Q(start_date__isnull=True) | Q(end_date__gte=F("start_date")), + name="courses_end_date_gte_start_date", + ), + models.CheckConstraint( + check=~Q(status=CourseContentStatus.COMPLETED) + | Q(is_completed=True), + name="courses_completed_status_implies_flag", + ), + models.CheckConstraint( + check=Q(is_completed=False) + | Q(status=CourseContentStatus.COMPLETED), + name="courses_completed_flag_implies_status", + ), + ] + + def __str__(self): + return f"Course<{self.id}> - {self.title}" + + def is_completed_by_date(self) -> bool: + if not self.end_date: + return False + return timezone.localdate() > self.end_date + + def clean(self): + super().clean() + + if ( + self.access_type == CourseAccessType.PROGRAM_MEMBERS + and self.partner_program_id is None + ): + raise ValidationError( + {"partner_program": "Поле обязательно для доступа участникам программы."} + ) + + has_start_date = self.start_date is not None + has_end_date = self.end_date is not None + if has_start_date != has_end_date: + raise ValidationError( + { + "start_date": "Дата старта и дата окончания должны быть заполнены вместе.", + "end_date": "Дата старта и дата окончания должны быть заполнены вместе.", + } + ) + + if has_start_date and has_end_date and self.end_date < self.start_date: + raise ValidationError( + {"end_date": "Дата окончания не может быть раньше даты старта."} + ) + + def save(self, *args, **kwargs): + self.full_clean() + + if self.is_completed_by_date(): + self.is_completed = True + self.status = CourseContentStatus.COMPLETED + if not self.completed_at: + self.completed_at = timezone.now() + + if self.status == CourseContentStatus.COMPLETED: + self.is_completed = True + if not self.completed_at: + self.completed_at = timezone.now() + + if self.is_completed and self.status != CourseContentStatus.COMPLETED: + self.status = CourseContentStatus.COMPLETED + if not self.completed_at: + self.completed_at = timezone.now() + + if self.status != CourseContentStatus.COMPLETED: + self.completed_at = None + + super().save(*args, **kwargs) diff --git a/courses/models/progress.py b/courses/models/progress.py new file mode 100644 index 00000000..53265e0e --- /dev/null +++ b/courses/models/progress.py @@ -0,0 +1,245 @@ +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.utils import timezone + +from .choices import ProgressStatus +from .content import CourseLesson, CourseModule, CourseTask +from .course import Course + + +def _status_percent_check(allow_blocked: bool = False) -> Q: + check = ( + Q(status=ProgressStatus.NOT_STARTED, percent=0) + | Q(status=ProgressStatus.IN_PROGRESS, percent__gte=1, percent__lte=99) + | Q(status=ProgressStatus.COMPLETED, percent=100) + ) + if allow_blocked: + check |= Q(status=ProgressStatus.BLOCKED, percent=0) + return check + + +class BaseUserProgress(models.Model): + status = models.CharField( + max_length=16, + choices=ProgressStatus.choices, + default=ProgressStatus.NOT_STARTED, + verbose_name="Статус прогресса", + ) + percent = models.PositiveSmallIntegerField( + default=0, + verbose_name="Прогресс, %", + ) + started_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата начала", + ) + completed_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата завершения", + ) + datetime_created = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + ) + datetime_updated = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + # blocked допускается только для прогресса урока + allow_blocked_status = False + + class Meta: + abstract = True + + def _validate_status_percent(self, errors: dict): + if self.status == ProgressStatus.NOT_STARTED and self.percent != 0: + errors["percent"] = "Для статуса not_started прогресс должен быть 0%." + + if self.status == ProgressStatus.IN_PROGRESS and not (0 < self.percent < 100): + errors["percent"] = "Для статуса in_progress прогресс должен быть от 1% до 99%." + + if self.status == ProgressStatus.COMPLETED and self.percent != 100: + errors["percent"] = "Для статуса completed прогресс должен быть 100%." + + if self.status == ProgressStatus.BLOCKED: + if not self.allow_blocked_status: + errors["status"] = "Статус blocked допустим только для прогресса урока." + elif self.percent != 0: + errors["percent"] = "Для статуса blocked прогресс должен быть 0%." + + def clean(self): + super().clean() + errors = {} + self._validate_status_percent(errors) + + if self.completed_at and self.status != ProgressStatus.COMPLETED: + errors["completed_at"] = "completed_at допустимо только для статуса completed." + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + validate = kwargs.pop("validate", True) + if self.status in (ProgressStatus.IN_PROGRESS, ProgressStatus.COMPLETED) and not self.started_at: + self.started_at = timezone.now() + + if self.status == ProgressStatus.COMPLETED and not self.completed_at: + self.completed_at = timezone.now() + + if self.status != ProgressStatus.COMPLETED: + self.completed_at = None + + if validate: + self.full_clean() + super().save(*args, **kwargs) + + +class UserCourseProgress(BaseUserProgress): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="course_progresses", + verbose_name="Пользователь", + ) + course = models.ForeignKey( + Course, + on_delete=models.CASCADE, + related_name="user_progresses", + verbose_name="Курс", + ) + last_visit_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата последнего визита", + ) + + class Meta: + verbose_name = "Прогресс пользователя по курсу" + verbose_name_plural = "Прогресс пользователей по курсам" + ordering = ("-datetime_updated", "id") + constraints = [ + models.UniqueConstraint( + fields=("user", "course"), + name="courses_user_course_progress_unique_user_course", + ), + models.CheckConstraint( + check=Q(percent__gte=0) & Q(percent__lte=100), + name="courses_user_course_progress_percent_0_100", + ), + models.CheckConstraint( + check=~Q(status=ProgressStatus.BLOCKED), + name="courses_user_course_progress_status_no_blocked", + ), + models.CheckConstraint( + check=_status_percent_check(allow_blocked=False), + name="courses_user_course_progress_status_percent_valid", + ), + ] + + def __str__(self): + return f"UserCourseProgress<{self.id}> - user={self.user_id} course={self.course_id}" + + +class UserModuleProgress(BaseUserProgress): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="module_progresses", + verbose_name="Пользователь", + ) + module = models.ForeignKey( + CourseModule, + on_delete=models.CASCADE, + related_name="user_progresses", + verbose_name="Модуль", + ) + + class Meta: + verbose_name = "Прогресс пользователя по модулю" + verbose_name_plural = "Прогресс пользователей по модулям" + ordering = ("-datetime_updated", "id") + constraints = [ + models.UniqueConstraint( + fields=("user", "module"), + name="courses_user_module_progress_unique_user_module", + ), + models.CheckConstraint( + check=Q(percent__gte=0) & Q(percent__lte=100), + name="courses_user_module_progress_percent_0_100", + ), + models.CheckConstraint( + check=~Q(status=ProgressStatus.BLOCKED), + name="courses_user_module_progress_status_no_blocked", + ), + models.CheckConstraint( + check=_status_percent_check(allow_blocked=False), + name="courses_user_module_progress_status_percent_valid", + ), + ] + + def __str__(self): + return f"UserModuleProgress<{self.id}> - user={self.user_id} module={self.module_id}" + + +class UserLessonProgress(BaseUserProgress): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="lesson_progresses", + verbose_name="Пользователь", + ) + lesson = models.ForeignKey( + CourseLesson, + on_delete=models.CASCADE, + related_name="user_progresses", + verbose_name="Урок", + ) + current_task = models.ForeignKey( + CourseTask, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="current_in_lesson_progress", + verbose_name="Текущее задание", + ) + + allow_blocked_status = True + + class Meta: + verbose_name = "Прогресс пользователя по уроку" + verbose_name_plural = "Прогресс пользователей по урокам" + ordering = ("-datetime_updated", "id") + constraints = [ + models.UniqueConstraint( + fields=("user", "lesson"), + name="courses_user_lesson_progress_unique_user_lesson", + ), + models.CheckConstraint( + check=Q(percent__gte=0) & Q(percent__lte=100), + name="courses_user_lesson_progress_percent_0_100", + ), + models.CheckConstraint( + check=_status_percent_check(allow_blocked=True), + name="courses_user_lesson_progress_status_percent_valid", + ), + ] + + def __str__(self): + return f"UserLessonProgress<{self.id}> - user={self.user_id} lesson={self.lesson_id}" + + def clean(self): + super().clean() + if self.current_task_id and self.lesson_id and self.current_task.lesson_id != self.lesson_id: + raise ValidationError( + {"current_task": "Текущее задание должно принадлежать этому уроку."} + ) + + def save(self, *args, **kwargs): + if self.status in (ProgressStatus.NOT_STARTED, ProgressStatus.BLOCKED): + self.current_task = None + super().save(*args, **kwargs) diff --git a/courses/serializers.py b/courses/serializers.py new file mode 100644 index 00000000..13eea2de --- /dev/null +++ b/courses/serializers.py @@ -0,0 +1,197 @@ +from rest_framework import serializers + +from courses.models import ( + CourseAccessType, + CourseContentStatus, + CourseLessonContentStatus, + CourseModuleContentStatus, + CourseTaskAnswerType, + CourseTaskCheckType, + CourseTaskContentStatus, + CourseTaskInformationalType, + CourseTaskKind, + CourseTaskQuestionType, + ProgressStatus, + UserTaskAnswerStatus, +) +from courses.services.access import ACTION_CONTINUE, ACTION_LOCK, ACTION_START +from courses.services.answers import TaskAnswerSubmitPayload + + +class CourseAnalyticsStubSerializer(serializers.Serializer): + enabled = serializers.BooleanField(default=False) + title = serializers.CharField(default="Аналитика") + state = serializers.ChoiceField(choices=("coming_soon",), default="coming_soon") + text = serializers.CharField(default="пока закрыто") + + +class CourseCardSerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField() + access_type = serializers.ChoiceField(choices=CourseAccessType.choices) + status = serializers.ChoiceField(choices=CourseContentStatus.choices) + avatar_url = serializers.URLField(allow_null=True) + card_cover_url = serializers.URLField(allow_null=True) + start_date = serializers.DateField(allow_null=True) + end_date = serializers.DateField(allow_null=True) + date_label = serializers.CharField() + is_available = serializers.BooleanField() + action_state = serializers.ChoiceField( + choices=(ACTION_START, ACTION_CONTINUE, ACTION_LOCK) + ) + progress_status = serializers.ChoiceField(choices=ProgressStatus.choices) + percent = serializers.IntegerField(min_value=0, max_value=100) + + +class CourseDetailSerializer(serializers.Serializer): + id = serializers.IntegerField() + title = serializers.CharField() + description = serializers.CharField(allow_blank=True) + access_type = serializers.ChoiceField(choices=CourseAccessType.choices) + status = serializers.ChoiceField(choices=CourseContentStatus.choices) + avatar_url = serializers.URLField(allow_null=True) + header_cover_url = serializers.URLField(allow_null=True) + start_date = serializers.DateField(allow_null=True) + end_date = serializers.DateField(allow_null=True) + date_label = serializers.CharField() + is_available = serializers.BooleanField() + progress_status = serializers.ChoiceField(choices=ProgressStatus.choices) + percent = serializers.IntegerField(min_value=0, max_value=100) + analytics_stub = CourseAnalyticsStubSerializer() + + +class CourseTaskOptionSerializer(serializers.Serializer): + id = serializers.IntegerField() + order = serializers.IntegerField(min_value=1) + text = serializers.CharField() + + +class LessonTaskSerializer(serializers.Serializer): + id = serializers.IntegerField() + order = serializers.IntegerField(min_value=1) + title = serializers.CharField() + status = serializers.ChoiceField(choices=CourseTaskContentStatus.choices) + task_kind = serializers.ChoiceField(choices=CourseTaskKind.choices) + check_type = serializers.ChoiceField( + choices=CourseTaskCheckType.choices, + allow_null=True, + ) + informational_type = serializers.ChoiceField( + choices=CourseTaskInformationalType.choices, + allow_null=True, + ) + question_type = serializers.ChoiceField( + choices=CourseTaskQuestionType.choices, + allow_null=True, + ) + answer_type = serializers.ChoiceField( + choices=CourseTaskAnswerType.choices, + allow_null=True, + ) + body_text = serializers.CharField(allow_blank=True) + video_url = serializers.URLField(allow_null=True) + image_url = serializers.URLField(allow_null=True) + attachment_url = serializers.URLField(allow_null=True) + is_available = serializers.BooleanField(default=False) + is_completed = serializers.BooleanField(default=False) + options = CourseTaskOptionSerializer(many=True, required=False) + + +class CourseLessonStructureSerializer(serializers.Serializer): + id = serializers.IntegerField() + module_id = serializers.IntegerField() + title = serializers.CharField() + order = serializers.IntegerField(min_value=1) + status = serializers.ChoiceField(choices=CourseLessonContentStatus.choices) + is_available = serializers.BooleanField() + progress_status = serializers.ChoiceField(choices=ProgressStatus.choices) + percent = serializers.IntegerField(min_value=0, max_value=100) + current_task_id = serializers.IntegerField(allow_null=True, required=False) + tasks = LessonTaskSerializer(many=True, required=False) + + +class CourseModuleStructureSerializer(serializers.Serializer): + id = serializers.IntegerField() + course_id = serializers.IntegerField() + title = serializers.CharField() + order = serializers.IntegerField(min_value=1) + start_date = serializers.DateField() + status = serializers.ChoiceField(choices=CourseModuleContentStatus.choices) + is_available = serializers.BooleanField() + progress_status = serializers.ChoiceField(choices=ProgressStatus.choices) + percent = serializers.IntegerField(min_value=0, max_value=100) + lessons = CourseLessonStructureSerializer(many=True, required=False) + + +class CourseStructureSerializer(serializers.Serializer): + course_id = serializers.IntegerField() + progress_status = serializers.ChoiceField(choices=ProgressStatus.choices) + percent = serializers.IntegerField(min_value=0, max_value=100) + modules = CourseModuleStructureSerializer(many=True) + + +class LessonDetailSerializer(serializers.Serializer): + id = serializers.IntegerField() + module_id = serializers.IntegerField() + course_id = serializers.IntegerField() + title = serializers.CharField() + progress_status = serializers.ChoiceField(choices=ProgressStatus.choices) + percent = serializers.IntegerField(min_value=0, max_value=100) + current_task_id = serializers.IntegerField(allow_null=True) + tasks = LessonTaskSerializer(many=True) + + +class TaskAnswerSubmitSerializer(serializers.Serializer): + answer_text = serializers.CharField(required=False, allow_blank=True, default="") + option_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + required=False, + default=list, + ) + file_ids = serializers.ListField( + child=serializers.URLField(), + required=False, + default=list, + ) + + def validate_option_ids(self, value: list[int]) -> list[int]: + if len(set(value)) != len(value): + raise serializers.ValidationError( + "Поле option_ids содержит повторяющиеся значения." + ) + return value + + def validate_file_ids(self, value: list[str]) -> list[str]: + if len(set(value)) != len(value): + raise serializers.ValidationError( + "Поле file_ids содержит повторяющиеся значения." + ) + return value + + def to_payload(self) -> TaskAnswerSubmitPayload: + data = self.validated_data + return TaskAnswerSubmitPayload( + answer_text=data.get("answer_text", ""), + option_ids=data.get("option_ids", []), + file_ids=data.get("file_ids", []), + ) + + +class TaskAnswerSubmitResultSerializer(serializers.Serializer): + answer_id = serializers.IntegerField(source="answer.id") + status = serializers.ChoiceField( + choices=UserTaskAnswerStatus.choices, + source="answer.status", + ) + is_correct = serializers.BooleanField(allow_null=True) + can_continue = serializers.BooleanField() + next_task_id = serializers.IntegerField(allow_null=True) + submitted_at = serializers.DateTimeField(source="answer.submitted_at") + + +class CourseVisitSerializer(serializers.Serializer): + pass + + +class CourseVisitResultSerializer(serializers.Serializer): + last_visit_at = serializers.DateTimeField() diff --git a/courses/services/__init__.py b/courses/services/__init__.py new file mode 100644 index 00000000..6c861a12 --- /dev/null +++ b/courses/services/__init__.py @@ -0,0 +1,83 @@ +from .access import ( + ACTION_CONTINUE, + ACTION_LOCK, + ACTION_START, + CourseAvailability, + CourseCardState, + is_lesson_available, + is_module_available, + resolve_course_action_state, + resolve_course_availability, + resolve_course_card_state, + resolve_course_date_label, +) +from .answers import ( + SubmitAnswerResult, + TaskAnswerSubmitPayload, + get_next_published_task, + submit_user_task_answer, +) +from .learning_flow import ( + answers_by_task, + ensure_lesson_access, + ensure_task_submission_access, + first_unfinished_task, + is_answer_completed, + not_started_progress_payload, + progress_payload, + recalculate_user_progresses_for_lesson, + task_completion_map, + task_completion_map_from_answers, +) +from .progress import ( + ProgressSnapshot, + build_progress_snapshot, + percent_from_counts, + status_from_percent, + touch_course_visit, + upsert_course_progress, + upsert_lesson_progress, + upsert_module_progress, +) +from .querysets import ( + published_course_queryset, + published_lessons_prefetch, +) + +__all__ = [ + "ACTION_START", + "ACTION_CONTINUE", + "ACTION_LOCK", + "CourseAvailability", + "CourseCardState", + "resolve_course_availability", + "resolve_course_action_state", + "resolve_course_date_label", + "resolve_course_card_state", + "is_module_available", + "is_lesson_available", + "TaskAnswerSubmitPayload", + "SubmitAnswerResult", + "submit_user_task_answer", + "get_next_published_task", + "not_started_progress_payload", + "progress_payload", + "is_answer_completed", + "answers_by_task", + "task_completion_map", + "task_completion_map_from_answers", + "first_unfinished_task", + "ensure_lesson_access", + "ensure_task_submission_access", + "recalculate_user_progresses_for_lesson", + "published_course_queryset", + "published_lessons_prefetch", + "ProgressSnapshot", + "percent_from_counts", + "status_from_percent", + "build_progress_snapshot", + "upsert_course_progress", + "upsert_module_progress", + "upsert_lesson_progress", + "touch_course_visit", +] diff --git a/courses/services/access.py b/courses/services/access.py new file mode 100644 index 00000000..e316bacf --- /dev/null +++ b/courses/services/access.py @@ -0,0 +1,181 @@ +from dataclasses import dataclass +from datetime import date, datetime +from zoneinfo import ZoneInfo + +from django.utils import timezone + +from courses.models import ( + Course, + CourseAccessType, + CourseContentStatus, + CourseLesson, + CourseLessonContentStatus, + CourseModule, + CourseModuleContentStatus, + ProgressStatus, + UserCourseProgress, +) + +MSK_TZ = ZoneInfo("Europe/Moscow") + +ACTION_START = "start" +ACTION_CONTINUE = "continue" +ACTION_LOCK = "lock" + + +@dataclass(slots=True, frozen=True) +class CourseAvailability: + is_available: bool + reason: str | None = None + + +@dataclass(slots=True, frozen=True) +class CourseCardState: + is_available: bool + action_state: str + date_label: str + + +def moscow_today(moment: datetime | None = None) -> date: + if moment is None: + moment = timezone.now() + if timezone.is_naive(moment): + moment = timezone.make_aware(moment, timezone.get_current_timezone()) + return moment.astimezone(MSK_TZ).date() + + +def is_course_completed(course: Course, *, today: date | None = None) -> bool: + current_day = today or moscow_today() + if course.status == CourseContentStatus.COMPLETED or course.is_completed: + return True + return bool(course.end_date and current_day > course.end_date) + + +def is_user_program_member(course: Course, user) -> bool: + if not getattr(user, "is_authenticated", False): + return False + if not course.partner_program_id: + return False + return course.partner_program.users.filter(pk=user.pk).exists() + + +def resolve_course_availability( + course: Course, + user, + *, + today: date | None = None, +) -> CourseAvailability: + if not getattr(user, "is_authenticated", False): + return CourseAvailability(is_available=False, reason="authentication_required") + + if course.status == CourseContentStatus.DRAFT: + return CourseAvailability(is_available=False, reason="draft") + + if is_course_completed(course, today=today): + return CourseAvailability(is_available=False, reason="completed") + + if course.access_type == CourseAccessType.SUBSCRIPTION_STUB: + return CourseAvailability(is_available=False, reason="subscription_required") + + if course.access_type == CourseAccessType.PROGRAM_MEMBERS: + if not course.partner_program_id: + return CourseAvailability(is_available=False, reason="program_not_set") + if not is_user_program_member(course, user): + return CourseAvailability(is_available=False, reason="not_program_member") + + return CourseAvailability(is_available=True) + + +def resolve_course_action_state( + course: Course, + user, + *, + progress: UserCourseProgress | None = None, + today: date | None = None, + availability: CourseAvailability | None = None, +) -> str: + resolved_availability = availability or resolve_course_availability( + course, user, today=today + ) + if not resolved_availability.is_available: + return ACTION_LOCK + + if progress is None or progress.status == ProgressStatus.NOT_STARTED: + return ACTION_START + if progress.status == ProgressStatus.IN_PROGRESS: + return ACTION_CONTINUE + return ACTION_LOCK + + +def resolve_course_date_label(course: Course, *, today: date | None = None) -> str: + current_day = today or moscow_today() + + if is_course_completed(course, today=current_day): + return "курс завершен" + + if not course.start_date and not course.end_date: + return "бессрочно" + + if course.start_date and course.end_date: + if current_day < course.start_date: + return f"{course.start_date:%d.%m.%y} - {course.end_date:%d.%m.%y}" + return f"доступен до {course.end_date:%d.%m.%Y}" + + return "" + + +def resolve_course_card_state( + course: Course, + user, + *, + progress: UserCourseProgress | None = None, + today: date | None = None, +) -> CourseCardState: + availability = resolve_course_availability(course, user, today=today) + action_state = resolve_course_action_state( + course, + user, + progress=progress, + today=today, + availability=availability, + ) + date_label = resolve_course_date_label(course, today=today) + return CourseCardState( + is_available=availability.is_available, + action_state=action_state, + date_label=date_label, + ) + + +def is_module_available( + module: CourseModule, + *, + course_available: bool, + previous_module_completed: bool, + today: date | None = None, +) -> bool: + current_day = today or moscow_today() + if not course_available: + return False + if module.status != CourseModuleContentStatus.PUBLISHED: + return False + if module.start_date and current_day < module.start_date: + return False + if not previous_module_completed: + return False + return True + + +def is_lesson_available( + lesson: CourseLesson, + *, + module_available: bool, + previous_lesson_completed: bool, +) -> bool: + if not module_available: + return False + if lesson.status != CourseLessonContentStatus.PUBLISHED: + return False + if not previous_lesson_completed: + return False + return True diff --git a/courses/services/answers.py b/courses/services/answers.py new file mode 100644 index 00000000..c47a8dfc --- /dev/null +++ b/courses/services/answers.py @@ -0,0 +1,306 @@ +from dataclasses import dataclass, field + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from files.models import UserFile + +from courses.models import ( + CourseTask, + CourseTaskAnswerType, + CourseTaskCheckType, + CourseTaskContentStatus, + CourseTaskKind, + CourseTaskOption, + UserTaskAnswer, + UserTaskAnswerFile, + UserTaskAnswerOption, + UserTaskAnswerStatus, +) +from courses.models.constants import DEFAULT_MAX_FILES_PER_ANSWER + + +@dataclass(slots=True) +class TaskAnswerSubmitPayload: + answer_text: str = "" + option_ids: list[int] = field(default_factory=list) + file_ids: list[str] = field(default_factory=list) + + +@dataclass(slots=True, frozen=True) +class SubmitAnswerResult: + answer: UserTaskAnswer + is_correct: bool | None + can_continue: bool + next_task_id: int | None + + +def _is_non_empty_text(value: str | None) -> bool: + return bool(value and value.strip()) + + +def _resolve_task_options( + task: CourseTask, option_ids: list[int] +) -> list[CourseTaskOption]: + if not option_ids: + return [] + + unique_ids = list(dict.fromkeys(option_ids)) + if len(unique_ids) != len(option_ids): + raise ValidationError({"option_ids": "Переданы дублирующиеся варианты ответа."}) + + options = list(task.options.filter(id__in=unique_ids)) + found_ids = {option.id for option in options} + missing_ids = [option_id for option_id in unique_ids if option_id not in found_ids] + if missing_ids: + raise ValidationError( + {"option_ids": f"Некоторые варианты ответа не найдены: {missing_ids}"} + ) + return options + + +def _resolve_user_files(file_ids: list[str]) -> list[UserFile]: + if not file_ids: + return [] + + unique_ids = list(dict.fromkeys(file_ids)) + if len(unique_ids) != len(file_ids): + raise ValidationError({"file_ids": "Переданы дублирующиеся файлы."}) + + files = list(UserFile.objects.filter(pk__in=unique_ids)) + files_by_id = {file.pk: file for file in files} + missing_ids = [file_id for file_id in unique_ids if file_id not in files_by_id] + if missing_ids: + raise ValidationError({"file_ids": f"Некоторые файлы не найдены: {missing_ids}"}) + return [files_by_id[file_id] for file_id in unique_ids] + + +def _validate_payload_by_answer_type( + task: CourseTask, + payload: TaskAnswerSubmitPayload, + *, + options: list[CourseTaskOption], + files: list[UserFile], +) -> None: + answer_type = task.answer_type + text_filled = _is_non_empty_text(payload.answer_text) + + if len(files) > DEFAULT_MAX_FILES_PER_ANSWER: + raise ValidationError( + {"file_ids": f"Максимум файлов в ответе: {DEFAULT_MAX_FILES_PER_ANSWER}."} + ) + + if answer_type == CourseTaskAnswerType.SINGLE_CHOICE and len(options) != 1: + raise ValidationError( + {"option_ids": "Для single_choice требуется выбрать ровно один вариант."} + ) + + if answer_type == CourseTaskAnswerType.MULTIPLE_CHOICE and not options: + raise ValidationError( + {"option_ids": "Для multiple_choice требуется выбрать хотя бы один вариант."} + ) + + if ( + answer_type + in ( + CourseTaskAnswerType.SINGLE_CHOICE, + CourseTaskAnswerType.MULTIPLE_CHOICE, + CourseTaskAnswerType.FILES, + ) + and text_filled + ): + raise ValidationError( + {"answer_text": "Для этого типа ответа текст не используется."} + ) + + if answer_type == CourseTaskAnswerType.TEXT and not text_filled: + raise ValidationError({"answer_text": "Для этого типа ответа требуется текст."}) + + if answer_type == CourseTaskAnswerType.FILES and not files: + raise ValidationError({"file_ids": "Для этого типа ответа требуется файл."}) + + if answer_type == CourseTaskAnswerType.TEXT_AND_FILES: + if not text_filled: + raise ValidationError( + {"answer_text": "Для этого типа ответа требуется текст."} + ) + if not files: + raise ValidationError({"file_ids": "Для этого типа ответа требуется файл."}) + + if ( + answer_type + in ( + CourseTaskAnswerType.TEXT, + CourseTaskAnswerType.FILES, + CourseTaskAnswerType.TEXT_AND_FILES, + ) + and options + ): + raise ValidationError( + {"option_ids": "Выбор варианта недопустим для этого типа ответа."} + ) + + +def _evaluate_answer( + task: CourseTask, + *, + normalized_text: str, + options: list[CourseTaskOption], + files: list[UserFile], +) -> bool: + if task.answer_type == CourseTaskAnswerType.SINGLE_CHOICE: + return bool(options and options[0].is_correct) + + if task.answer_type == CourseTaskAnswerType.MULTIPLE_CHOICE: + selected_ids = {option.id for option in options} + correct_ids = set( + task.options.filter(is_correct=True).values_list("id", flat=True) + ) + return bool(selected_ids and selected_ids == correct_ids) + + if task.answer_type == CourseTaskAnswerType.TEXT: + return _is_non_empty_text(normalized_text) + + if task.answer_type == CourseTaskAnswerType.FILES: + return bool(files) + + if task.answer_type == CourseTaskAnswerType.TEXT_AND_FILES: + return _is_non_empty_text(normalized_text) and bool(files) + + return False + + +def get_next_published_task(task: CourseTask) -> CourseTask | None: + return ( + task.lesson.tasks.filter( + status=CourseTaskContentStatus.PUBLISHED, + order__gt=task.order, + ) + .order_by("order", "id") + .first() + ) + + +def submit_user_task_answer( + user, + task: CourseTask, + payload: TaskAnswerSubmitPayload, +) -> SubmitAnswerResult: + if task.status != CourseTaskContentStatus.PUBLISHED: + raise ValidationError( + {"task": "Отправка ответа доступна только для опубликованных заданий."} + ) + + if task.task_kind != CourseTaskKind.QUESTION: + raise ValidationError( + {"task": "Отправка ответа доступна только для вопросных заданий."} + ) + + if not task.answer_type: + raise ValidationError({"task": "У задания не задан тип ответа."}) + if not task.check_type: + raise ValidationError({"task": "У задания не задан тип проверки."}) + + selected_options = _resolve_task_options(task, payload.option_ids) + selected_files = _resolve_user_files(payload.file_ids) + _validate_payload_by_answer_type( + task, + payload, + options=selected_options, + files=selected_files, + ) + + normalized_text = (payload.answer_text or "").strip() + submitted_at = timezone.now() + manager = UserTaskAnswer.objects + if transaction.get_connection().in_atomic_block: + manager = manager.select_for_update() + answer = manager.filter(user=user, task=task).first() + if answer is None: + answer = UserTaskAnswer(user=user, task=task) + + answer.answer_text = normalized_text + answer.submitted_at = submitted_at + answer.review_comment = "" + answer.reviewed_by = None + answer.reviewed_at = None + + if task.check_type == CourseTaskCheckType.WITH_REVIEW: + answer.status = UserTaskAnswerStatus.PENDING_REVIEW + answer.is_correct = None + can_continue = False + else: + answer.status = UserTaskAnswerStatus.SUBMITTED + answer.is_correct = _evaluate_answer( + task, + normalized_text=normalized_text, + options=selected_options, + files=selected_files, + ) + can_continue = bool(answer.is_correct) + + try: + answer.save(validate=False) + except DjangoValidationError as exc: + if hasattr(exc, "message_dict"): + raise ValidationError(exc.message_dict) from exc + raise ValidationError({"detail": exc.messages}) from exc + except IntegrityError: + retry_manager = UserTaskAnswer.objects + if transaction.get_connection().in_atomic_block: + retry_manager = retry_manager.select_for_update() + answer = retry_manager.get(user=user, task=task) + answer.answer_text = normalized_text + answer.submitted_at = submitted_at + answer.review_comment = "" + answer.reviewed_by = None + answer.reviewed_at = None + + if task.check_type == CourseTaskCheckType.WITH_REVIEW: + answer.status = UserTaskAnswerStatus.PENDING_REVIEW + answer.is_correct = None + can_continue = False + else: + answer.status = UserTaskAnswerStatus.SUBMITTED + answer.is_correct = _evaluate_answer( + task, + normalized_text=normalized_text, + options=selected_options, + files=selected_files, + ) + can_continue = bool(answer.is_correct) + answer.save(validate=False) + + answer.selected_options.all().delete() + answer.files.all().delete() + + if selected_options: + UserTaskAnswerOption.objects.bulk_create( + [ + UserTaskAnswerOption(answer=answer, option=option) + for option in selected_options + ] + ) + + if selected_files: + UserTaskAnswerFile.objects.bulk_create( + [ + UserTaskAnswerFile( + answer=answer, + file=user_file, + file_name=user_file.name, + file_size=user_file.size, + ) + for user_file in selected_files + ] + ) + + next_task = get_next_published_task(task) if can_continue else None + return SubmitAnswerResult( + answer=answer, + is_correct=answer.is_correct, + can_continue=can_continue, + next_task_id=next_task.id if next_task else None, + ) diff --git a/courses/services/learning_flow.py b/courses/services/learning_flow.py new file mode 100644 index 00000000..9e6000fe --- /dev/null +++ b/courses/services/learning_flow.py @@ -0,0 +1,239 @@ +from rest_framework.exceptions import PermissionDenied + +from courses.models import ( + CourseLesson, + CourseLessonContentStatus, + CourseModuleContentStatus, + CourseTask, + CourseTaskCheckType, + CourseTaskContentStatus, + CourseTaskKind, + ProgressStatus, + UserLessonProgress, + UserModuleProgress, + UserTaskAnswer, + UserTaskAnswerStatus, +) +from courses.services.access import ( + is_lesson_available, + is_module_available, + resolve_course_availability, +) +from courses.services.progress import ( + upsert_course_progress, + upsert_lesson_progress, + upsert_module_progress, +) + + +def not_started_progress_payload() -> dict: + return {"status": ProgressStatus.NOT_STARTED, "percent": 0} + + +def progress_payload(progress) -> dict: + if progress is None: + return not_started_progress_payload() + return { + "status": progress.status, + "percent": progress.percent, + } + + +def is_answer_completed(task: CourseTask, answer: UserTaskAnswer | None) -> bool: + if task.task_kind == CourseTaskKind.INFORMATIONAL: + return True + if answer is None: + return False + if task.check_type == CourseTaskCheckType.WITH_REVIEW: + return answer.status == UserTaskAnswerStatus.ACCEPTED + return bool(answer.is_correct) + + +def answers_by_task(user, tasks: list[CourseTask]) -> dict[int, UserTaskAnswer]: + if not tasks: + return {} + task_ids = [task.id for task in tasks] + answers = UserTaskAnswer.objects.filter(user=user, task_id__in=task_ids) + return {answer.task_id: answer for answer in answers} + + +def task_completion_map( + user, + tasks: list[CourseTask], +) -> dict[int, bool]: + answer_map = answers_by_task(user, tasks) + return task_completion_map_from_answers(tasks, answer_map) + + +def task_completion_map_from_answers( + tasks: list[CourseTask], + answer_map: dict[int, UserTaskAnswer], +) -> dict[int, bool]: + return { + task.id: is_answer_completed(task, answer_map.get(task.id)) + for task in tasks + } + + +def first_unfinished_task( + tasks: list[CourseTask], + completion_map: dict[int, bool], +) -> CourseTask | None: + for task in tasks: + if not completion_map.get(task.id, False): + return task + return None + + +def ensure_lesson_access(user, lesson: CourseLesson) -> None: + module = lesson.module + course = module.course + course_availability = resolve_course_availability(course, user) + modules = list( + course.modules.filter(status=CourseModuleContentStatus.PUBLISHED).order_by( + "order", "id" + ) + ) + module_progress_map = { + progress.module_id: progress + for progress in UserModuleProgress.objects.filter( + user=user, module_id__in=[item.id for item in modules] + ) + } + + previous_module_completed = True + module_available = False + for ordered_module in modules: + module_progress = module_progress_map.get(ordered_module.id) + module_completed = bool( + module_progress + and module_progress.status == ProgressStatus.COMPLETED + ) + ordered_module_available = is_module_available( + ordered_module, + course_available=course_availability.is_available, + previous_module_completed=previous_module_completed, + ) + if ordered_module.id == module.id: + module_available = ordered_module_available + break + previous_module_completed = module_completed + + lessons = list( + module.lessons.filter(status=CourseLessonContentStatus.PUBLISHED).order_by( + "order", "id" + ) + ) + lesson_progress_map = { + progress.lesson_id: progress + for progress in UserLessonProgress.objects.filter( + user=user, lesson_id__in=[item.id for item in lessons] + ) + } + + previous_lesson_completed = True + lesson_available = False + for ordered_lesson in lessons: + lesson_progress = lesson_progress_map.get(ordered_lesson.id) + lesson_completed = bool( + lesson_progress + and lesson_progress.status == ProgressStatus.COMPLETED + ) + ordered_lesson_available = is_lesson_available( + ordered_lesson, + module_available=module_available, + previous_lesson_completed=previous_lesson_completed, + ) + if ordered_lesson.id == lesson.id: + lesson_available = ordered_lesson_available + break + previous_lesson_completed = lesson_completed + + if not lesson_available: + raise PermissionDenied("Урок пока недоступен.") + + +def ensure_task_submission_access(user, task: CourseTask) -> None: + lesson = task.lesson + ensure_lesson_access(user, lesson) + + tasks = list( + lesson.tasks.filter(status=CourseTaskContentStatus.PUBLISHED).order_by( + "order", "id" + ) + ) + completion_map = task_completion_map(user, tasks) + current_task = first_unfinished_task(tasks, completion_map) + if current_task is None: + raise PermissionDenied("Все задания в уроке уже завершены.") + if current_task.id != task.id: + raise PermissionDenied( + "Задание недоступно для отправки: необходимо соблюдать порядок." + ) + + +def recalculate_user_progresses_for_lesson(user, lesson: CourseLesson) -> None: + module = lesson.module + course = module.course + + # Recalculate only the touched lesson from its published tasks. + lesson_tasks = list( + lesson.tasks.filter(status=CourseTaskContentStatus.PUBLISHED).order_by( + "order", + "id", + ) + ) + lesson_answer_map = answers_by_task(user, lesson_tasks) + lesson_completion_map = task_completion_map_from_answers( + lesson_tasks, + lesson_answer_map, + ) + lesson_total_tasks = len(lesson_tasks) + lesson_completed_tasks = sum( + 1 for task in lesson_tasks if lesson_completion_map.get(task.id, False) + ) + current_task = first_unfinished_task(lesson_tasks, lesson_completion_map) + upsert_lesson_progress( + user, + lesson, + completed_tasks=lesson_completed_tasks, + total_tasks=lesson_total_tasks, + current_task=current_task, + ) + + # Module progress depends on completed lesson progresses inside this module. + module_total_lessons = module.lessons.filter( + status=CourseLessonContentStatus.PUBLISHED + ).count() + module_completed_lessons = UserLessonProgress.objects.filter( + user=user, + lesson__module=module, + lesson__status=CourseLessonContentStatus.PUBLISHED, + status=ProgressStatus.COMPLETED, + ).count() + upsert_module_progress( + user, + module, + completed_lessons=module_completed_lessons, + total_lessons=module_total_lessons, + ) + + # Course progress depends on completed lesson progresses across published modules. + course_total_lessons = CourseLesson.objects.filter( + module__course=course, + module__status=CourseModuleContentStatus.PUBLISHED, + status=CourseLessonContentStatus.PUBLISHED, + ).count() + course_completed_lessons = UserLessonProgress.objects.filter( + user=user, + lesson__module__course=course, + lesson__module__status=CourseModuleContentStatus.PUBLISHED, + lesson__status=CourseLessonContentStatus.PUBLISHED, + status=ProgressStatus.COMPLETED, + ).count() + upsert_course_progress( + user, + course, + completed_lessons=course_completed_lessons, + total_lessons=course_total_lessons, + ) diff --git a/courses/services/progress.py b/courses/services/progress.py new file mode 100644 index 00000000..10e4a543 --- /dev/null +++ b/courses/services/progress.py @@ -0,0 +1,190 @@ +from dataclasses import dataclass +from datetime import datetime + +from django.db import IntegrityError, transaction +from django.utils import timezone + +from courses.models import ( + Course, + CourseLesson, + CourseModule, + ProgressStatus, + UserCourseProgress, + UserLessonProgress, + UserModuleProgress, +) + + +@dataclass(slots=True, frozen=True) +class ProgressSnapshot: + status: str + percent: int + + +def percent_from_counts(completed_count: int, total_count: int) -> int: + if total_count <= 0: + return 0 + if completed_count <= 0: + return 0 + if completed_count >= total_count: + return 100 + return int((completed_count * 100) / total_count) + + +def status_from_percent( + percent: int, + *, + allow_blocked: bool = False, + blocked: bool = False, +) -> str: + if blocked and allow_blocked: + return ProgressStatus.BLOCKED + if percent <= 0: + return ProgressStatus.NOT_STARTED + if percent >= 100: + return ProgressStatus.COMPLETED + return ProgressStatus.IN_PROGRESS + + +def build_progress_snapshot( + completed_count: int, + total_count: int, + *, + allow_blocked: bool = False, + blocked: bool = False, +) -> ProgressSnapshot: + percent = percent_from_counts(completed_count, total_count) + status = status_from_percent(percent, allow_blocked=allow_blocked, blocked=blocked) + return ProgressSnapshot(status=status, percent=percent) + + +def upsert_course_progress( + user, + course: Course, + *, + completed_lessons: int, + total_lessons: int, + touch_visit: bool = False, + visited_at: datetime | None = None, +) -> UserCourseProgress: + snapshot = build_progress_snapshot(completed_lessons, total_lessons) + manager = UserCourseProgress.objects + if transaction.get_connection().in_atomic_block: + manager = manager.select_for_update() + progress = manager.filter(user=user, course=course).first() + if progress is None: + progress = UserCourseProgress(user=user, course=course) + + progress.status = snapshot.status + progress.percent = snapshot.percent + if touch_visit: + progress.last_visit_at = visited_at or timezone.now() + try: + progress.save(validate=False) + except IntegrityError: + # Concurrent create: retry as locked update. + retry_manager = UserCourseProgress.objects + if transaction.get_connection().in_atomic_block: + retry_manager = retry_manager.select_for_update() + progress = retry_manager.get(user=user, course=course) + progress.status = snapshot.status + progress.percent = snapshot.percent + if touch_visit: + progress.last_visit_at = visited_at or timezone.now() + progress.save(validate=False) + return progress + + +def upsert_module_progress( + user, + module: CourseModule, + *, + completed_lessons: int, + total_lessons: int, +) -> UserModuleProgress: + snapshot = build_progress_snapshot(completed_lessons, total_lessons) + manager = UserModuleProgress.objects + if transaction.get_connection().in_atomic_block: + manager = manager.select_for_update() + progress = manager.filter(user=user, module=module).first() + if progress is None: + progress = UserModuleProgress(user=user, module=module) + + progress.status = snapshot.status + progress.percent = snapshot.percent + try: + progress.save(validate=False) + except IntegrityError: + retry_manager = UserModuleProgress.objects + if transaction.get_connection().in_atomic_block: + retry_manager = retry_manager.select_for_update() + progress = retry_manager.get(user=user, module=module) + progress.status = snapshot.status + progress.percent = snapshot.percent + progress.save(validate=False) + return progress + + +def upsert_lesson_progress( + user, + lesson: CourseLesson, + *, + completed_tasks: int, + total_tasks: int, + current_task=None, + blocked: bool = False, +) -> UserLessonProgress: + snapshot = build_progress_snapshot( + completed_tasks, + total_tasks, + allow_blocked=True, + blocked=blocked, + ) + manager = UserLessonProgress.objects + if transaction.get_connection().in_atomic_block: + manager = manager.select_for_update() + progress = manager.filter(user=user, lesson=lesson).first() + if progress is None: + progress = UserLessonProgress(user=user, lesson=lesson) + + progress.status = snapshot.status + progress.percent = snapshot.percent + progress.current_task = current_task + try: + progress.save(validate=False) + except IntegrityError: + retry_manager = UserLessonProgress.objects + if transaction.get_connection().in_atomic_block: + retry_manager = retry_manager.select_for_update() + progress = retry_manager.get(user=user, lesson=lesson) + progress.status = snapshot.status + progress.percent = snapshot.percent + progress.current_task = current_task + progress.save(validate=False) + return progress + + +@transaction.atomic +def touch_course_visit( + user, + course: Course, + *, + visited_at: datetime | None = None, +) -> UserCourseProgress: + progress = UserCourseProgress.objects.select_for_update().filter( + user=user, + course=course, + ).first() + if progress is None: + progress = UserCourseProgress(user=user, course=course) + progress.last_visit_at = visited_at or timezone.now() + try: + progress.save(validate=False) + except IntegrityError: + progress = UserCourseProgress.objects.select_for_update().get( + user=user, + course=course, + ) + progress.last_visit_at = visited_at or timezone.now() + progress.save(validate=False) + return progress diff --git a/courses/services/querysets.py b/courses/services/querysets.py new file mode 100644 index 00000000..8f3dc051 --- /dev/null +++ b/courses/services/querysets.py @@ -0,0 +1,21 @@ +from django.db.models import Prefetch + +from courses.models import ( + Course, + CourseContentStatus, + CourseLesson, + CourseLessonContentStatus, +) + + +def published_course_queryset(): + return Course.objects.exclude(status=CourseContentStatus.DRAFT) + + +def published_lessons_prefetch(): + return Prefetch( + "lessons", + queryset=CourseLesson.objects.filter( + status=CourseLessonContentStatus.PUBLISHED + ).order_by("order", "id"), + ) diff --git a/courses/urls.py b/courses/urls.py new file mode 100644 index 00000000..fa4d77ee --- /dev/null +++ b/courses/urls.py @@ -0,0 +1,33 @@ +from django.urls import path + +from .views import ( + CourseDetailAPIView, + CourseListAPIView, + CourseStructureAPIView, + CourseTaskAnswerSubmitAPIView, + CourseVisitAPIView, + LessonDetailAPIView, +) + +app_name = "courses" + +urlpatterns = [ + path("", CourseListAPIView.as_view(), name="course-list"), + path("/", CourseDetailAPIView.as_view(), name="course-detail"), + path( + "/structure/", + CourseStructureAPIView.as_view(), + name="course-structure", + ), + path( + "/visit/", + CourseVisitAPIView.as_view(), + name="course-visit", + ), + path("lessons//", LessonDetailAPIView.as_view(), name="lesson-detail"), + path( + "tasks//answer/", + CourseTaskAnswerSubmitAPIView.as_view(), + name="task-answer-submit", + ), +] diff --git a/courses/views.py b/courses/views.py new file mode 100644 index 00000000..bcceddf6 --- /dev/null +++ b/courses/views.py @@ -0,0 +1,403 @@ +from django.db import transaction +from django.db.models import Prefetch +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from courses.models import ( + CourseContentStatus, + CourseLesson, + CourseLessonContentStatus, + CourseModule, + CourseModuleContentStatus, + CourseTask, + CourseTaskAnswerType, + CourseTaskContentStatus, + CourseTaskKind, + CourseTaskOption, + ProgressStatus, + UserCourseProgress, + UserLessonProgress, + UserModuleProgress, +) +from courses.serializers import ( + CourseCardSerializer, + CourseDetailSerializer, + CourseStructureSerializer, + CourseVisitResultSerializer, + CourseVisitSerializer, + LessonDetailSerializer, + TaskAnswerSubmitResultSerializer, + TaskAnswerSubmitSerializer, +) +from courses.services.access import ( + is_lesson_available, + is_module_available, + resolve_course_availability, + resolve_course_card_state, +) +from courses.services.answers import submit_user_task_answer +from courses.services.learning_flow import ( + ensure_lesson_access, + ensure_task_submission_access, + first_unfinished_task, + progress_payload, + recalculate_user_progresses_for_lesson, + task_completion_map, +) +from courses.services.progress import build_progress_snapshot, touch_course_visit +from courses.services.querysets import ( + published_course_queryset, + published_lessons_prefetch, +) + + +class AuthenticatedCourseAPIView(APIView): + permission_classes = [IsAuthenticated] + + +class CourseListAPIView(AuthenticatedCourseAPIView): + + def get(self, request): + courses = list( + published_course_queryset() + .select_related("partner_program", "avatar_file", "card_cover_file") + .order_by("-datetime_created") + ) + progress_map = { + progress.course_id: progress + for progress in UserCourseProgress.objects.filter( + user=request.user, + course_id__in=[course.id for course in courses], + ) + } + + data = [] + for course in courses: + progress = progress_map.get(course.id) + card_state = resolve_course_card_state(course, request.user, progress=progress) + normalized_progress = progress_payload(progress) + data.append( + { + "id": course.id, + "title": course.title, + "access_type": course.access_type, + "status": course.status, + "avatar_url": course.avatar_file_id, + "card_cover_url": course.card_cover_file_id, + "start_date": course.start_date, + "end_date": course.end_date, + "date_label": card_state.date_label, + "is_available": card_state.is_available, + "action_state": card_state.action_state, + "progress_status": normalized_progress["status"], + "percent": normalized_progress["percent"], + } + ) + + serializer = CourseCardSerializer(data=data, many=True) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + +class CourseDetailAPIView(AuthenticatedCourseAPIView): + + def get(self, request, pk: int): + course = get_object_or_404( + published_course_queryset().select_related( + "partner_program", + "avatar_file", + "header_cover_file", + ), + pk=pk, + ) + progress = ( + UserCourseProgress.objects.filter(user=request.user, course=course).first() + ) + normalized_progress = progress_payload(progress) + card_state = resolve_course_card_state(course, request.user, progress=progress) + + serializer = CourseDetailSerializer( + data={ + "id": course.id, + "title": course.title, + "description": course.description, + "access_type": course.access_type, + "status": course.status, + "avatar_url": course.avatar_file_id, + "header_cover_url": course.header_cover_file_id, + "start_date": course.start_date, + "end_date": course.end_date, + "date_label": card_state.date_label, + "is_available": card_state.is_available, + "progress_status": normalized_progress["status"], + "percent": normalized_progress["percent"], + "analytics_stub": { + "enabled": False, + "title": "Аналитика", + "state": "coming_soon", + "text": "пока закрыто", + }, + } + ) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + +class CourseStructureAPIView(AuthenticatedCourseAPIView): + + def get(self, request, pk: int): + course = get_object_or_404( + published_course_queryset().prefetch_related( + Prefetch( + "modules", + queryset=CourseModule.objects.filter( + status=CourseModuleContentStatus.PUBLISHED + ) + .order_by("order", "id") + .prefetch_related(published_lessons_prefetch()), + ), + ), + pk=pk, + ) + modules = list(course.modules.all()) + module_ids = [module.id for module in modules] + lessons = [lesson for module in modules for lesson in module.lessons.all()] + lesson_ids = [lesson.id for lesson in lessons] + + module_progress_map = { + progress.module_id: progress + for progress in UserModuleProgress.objects.filter( + user=request.user, module_id__in=module_ids + ) + } + lesson_progress_map = { + progress.lesson_id: progress + for progress in UserLessonProgress.objects.filter( + user=request.user, lesson_id__in=lesson_ids + ) + } + course_progress = ( + UserCourseProgress.objects.filter(user=request.user, course=course).first() + ) + course_progress_payload = progress_payload(course_progress) + course_availability = resolve_course_availability(course, request.user) + + modules_payload = [] + previous_module_completed = True + for module in modules: + module_progress = module_progress_map.get(module.id) + module_progress_payload = progress_payload(module_progress) + module_completed = module_progress_payload["status"] == ProgressStatus.COMPLETED + module_available = is_module_available( + module, + course_available=course_availability.is_available, + previous_module_completed=previous_module_completed, + ) + + lessons_payload = [] + previous_lesson_completed = True + for lesson in module.lessons.all(): + lesson_progress = lesson_progress_map.get(lesson.id) + lesson_progress_payload = progress_payload(lesson_progress) + lesson_completed = ( + lesson_progress_payload["status"] == ProgressStatus.COMPLETED + ) + lesson_available = is_lesson_available( + lesson, + module_available=module_available, + previous_lesson_completed=previous_lesson_completed, + ) + lesson_status = lesson_progress_payload["status"] + lesson_percent = lesson_progress_payload["percent"] + if not lesson_available and lesson_status != ProgressStatus.COMPLETED: + lesson_status = ProgressStatus.BLOCKED + lesson_percent = 0 + + lessons_payload.append( + { + "id": lesson.id, + "module_id": module.id, + "title": lesson.title, + "order": lesson.order, + "status": lesson.status, + "is_available": lesson_available, + "progress_status": lesson_status, + "percent": lesson_percent, + "current_task_id": ( + lesson_progress.current_task_id if lesson_progress else None + ), + } + ) + previous_lesson_completed = lesson_completed + + modules_payload.append( + { + "id": module.id, + "course_id": course.id, + "title": module.title, + "order": module.order, + "start_date": module.start_date, + "status": module.status, + "is_available": module_available, + "progress_status": module_progress_payload["status"], + "percent": module_progress_payload["percent"], + "lessons": lessons_payload, + } + ) + previous_module_completed = module_completed + + serializer = CourseStructureSerializer( + data={ + "course_id": course.id, + "progress_status": course_progress_payload["status"], + "percent": course_progress_payload["percent"], + "modules": modules_payload, + } + ) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + +class LessonDetailAPIView(AuthenticatedCourseAPIView): + + def get(self, request, pk: int): + lesson = get_object_or_404( + CourseLesson.objects.filter( + status=CourseLessonContentStatus.PUBLISHED, + module__status=CourseModuleContentStatus.PUBLISHED, + module__course__status__in=( + CourseContentStatus.PUBLISHED, + CourseContentStatus.COMPLETED, + ), + ) + .select_related("module", "module__course") + .prefetch_related( + Prefetch( + "tasks", + queryset=CourseTask.objects.filter( + status=CourseTaskContentStatus.PUBLISHED + ) + .order_by("order", "id") + .prefetch_related( + Prefetch( + "options", + queryset=CourseTaskOption.objects.order_by("order", "id"), + ) + ), + ), + ), + pk=pk, + ) + + ensure_lesson_access(request.user, lesson) + tasks = list(lesson.tasks.all()) + completion_map = task_completion_map(request.user, tasks) + current_task = first_unfinished_task(tasks, completion_map) + total_tasks = len(tasks) + completed_tasks = sum(1 for task in tasks if completion_map.get(task.id, False)) + snapshot = build_progress_snapshot(completed_tasks, total_tasks) + current_task_id = current_task.id if current_task is not None else None + + tasks_payload = [] + for task in tasks: + is_completed = completion_map.get(task.id, False) + is_available = is_completed or ( + current_task_id is not None and task.id == current_task_id + ) + if current_task_id is None: + is_available = True + + options_payload = [] + if task.answer_type in ( + CourseTaskAnswerType.SINGLE_CHOICE, + CourseTaskAnswerType.MULTIPLE_CHOICE, + ): + options_payload = [ + {"id": option.id, "order": option.order, "text": option.text} + for option in task.options.all() + ] + + tasks_payload.append( + { + "id": task.id, + "order": task.order, + "title": task.title, + "status": task.status, + "task_kind": task.task_kind, + "check_type": task.check_type, + "informational_type": task.informational_type, + "question_type": task.question_type, + "answer_type": task.answer_type, + "body_text": task.body_text, + "video_url": task.video_url, + "image_url": task.image_file_id, + "attachment_url": task.attachment_file_id, + "is_available": is_available, + "is_completed": is_completed, + "options": options_payload, + } + ) + + serializer = LessonDetailSerializer( + data={ + "id": lesson.id, + "module_id": lesson.module_id, + "course_id": lesson.module.course_id, + "title": lesson.title, + "progress_status": snapshot.status, + "percent": snapshot.percent, + "current_task_id": current_task_id, + "tasks": tasks_payload, + } + ) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + +class CourseTaskAnswerSubmitAPIView(AuthenticatedCourseAPIView): + + def post(self, request, pk: int): + task = get_object_or_404( + CourseTask.objects.filter( + status=CourseTaskContentStatus.PUBLISHED, + task_kind=CourseTaskKind.QUESTION, + lesson__status=CourseLessonContentStatus.PUBLISHED, + lesson__module__status=CourseModuleContentStatus.PUBLISHED, + lesson__module__course__status__in=( + CourseContentStatus.PUBLISHED, + CourseContentStatus.COMPLETED, + ), + ) + .select_related("lesson", "lesson__module", "lesson__module__course"), + pk=pk, + ) + ensure_task_submission_access(request.user, task) + + serializer = TaskAnswerSubmitSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + with transaction.atomic(): + result = submit_user_task_answer( + request.user, + task, + serializer.to_payload(), + ) + recalculate_user_progresses_for_lesson(request.user, task.lesson) + + result_serializer = TaskAnswerSubmitResultSerializer(result) + return Response(result_serializer.data, status=status.HTTP_200_OK) + + +class CourseVisitAPIView(AuthenticatedCourseAPIView): + + def post(self, request, pk: int): + serializer = CourseVisitSerializer(data=request.data or {}) + serializer.is_valid(raise_exception=True) + course = get_object_or_404(published_course_queryset(), pk=pk) + progress = touch_course_visit(request.user, course) + result_serializer = CourseVisitResultSerializer( + {"last_visit_at": progress.last_visit_at} + ) + return Response(result_serializer.data, status=status.HTTP_200_OK) diff --git a/procollab/settings.py b/procollab/settings.py index 2a736412..acb2acd8 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -95,6 +95,7 @@ "files.apps.FilesConfig", "events.apps.EventsConfig", "partner_programs.apps.PartnerProgramsConfig", + "courses.apps.CoursesConfig", "mailing.apps.MailingConfig", "feed.apps.FeedConfig", "project_rates.apps.ProjectRatesConfig", diff --git a/procollab/urls.py b/procollab/urls.py index 2a59172e..634fa9da 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -48,6 +48,7 @@ path("chats/", include("chats.urls", namespace="chats")), path("events/", include("events.urls", namespace="events")), path("programs/", include("partner_programs.urls", namespace="partner_programs")), + path("courses/", include("courses.urls", namespace="courses")), path("rate-project/", include(("project_rates.urls", "rate_projects"))), path("feed/", include("feed.urls", namespace="feed")), path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),