From 12bc951b962efc557d4e188e754299acc2cc786c Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 8 May 2026 10:39:21 +0500 Subject: [PATCH 1/4] =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 83 +++++++++----------------------- docs/api.md | 33 +++++++++++++ docs/architecture.md | 33 +++++++++++++ docs/development.md | 29 +++++++++++ docs/devops-state.md | 29 +++++++++++ docs/{ => modules}/chats.md | 73 ++++++++++++++-------------- docs/modules/core.md | 3 ++ docs/modules/courses.md | 3 ++ docs/modules/events.md | 3 ++ docs/modules/feed.md | 3 ++ docs/modules/files.md | 3 ++ docs/modules/industries.md | 3 ++ docs/modules/invites.md | 3 ++ docs/modules/mailing.md | 3 ++ docs/modules/metrics.md | 3 ++ docs/modules/news.md | 3 ++ docs/modules/partner-programs.md | 3 ++ docs/modules/project-rates.md | 3 ++ docs/modules/projects.md | 3 ++ docs/modules/readme.md | 50 +++++++++++++++++++ docs/modules/users.md | 3 ++ docs/modules/vacancy.md | 3 ++ docs/readme.md | 31 ++++++++++-- 24 files changed, 305 insertions(+), 102 deletions(-) create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/development.md create mode 100644 docs/devops-state.md rename docs/{ => modules}/chats.md (75%) create mode 100644 docs/modules/core.md create mode 100644 docs/modules/courses.md create mode 100644 docs/modules/events.md create mode 100644 docs/modules/feed.md create mode 100644 docs/modules/files.md create mode 100644 docs/modules/industries.md create mode 100644 docs/modules/invites.md create mode 100644 docs/modules/mailing.md create mode 100644 docs/modules/metrics.md create mode 100644 docs/modules/news.md create mode 100644 docs/modules/partner-programs.md create mode 100644 docs/modules/project-rates.md create mode 100644 docs/modules/projects.md create mode 100644 docs/modules/readme.md create mode 100644 docs/modules/users.md create mode 100644 docs/modules/vacancy.md diff --git a/.gitignore b/.gitignore index c5bba638..5ee01578 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ coverage.xml .hypothesis/ .pytest_cache/ .idea/ +.codex # Translations *.mo diff --git a/README.md b/README.md index 186e355b..e5bd3a67 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,31 @@ -# Procollab backend service +# Procollab Backend -## Usage +Backend API для продукта Procollab. -### Clone project +## Стек -📌 `git clone https://github.com/procollab-github/api.git` +- Python +- Django +- Django REST Framework +- Channels +- Celery +- PostgreSQL +- Redis -### Create virtual environment - -🔑 Copy `.env.example` to `.env` and change api settings - -### Install dependencies - -* 🐍 Install poetry with command `pip install poetry` -* 📎 Install dependencies with command `poetry install` - -### Accept migrations - -🎓 Run `python manage.py migrate` - -### Run project - -🚀 Run project via `python manage.py runserver` -## For developers - -### Install pre-commit hooks - -To install pre-commit simply run inside the shell: +## Базовые команды ```bash -pre-commit install -``` - -To run it on all of your files, do - -```bash -pre-commit run --all-files -``` - -## Troubleshooting - -## Errors caused by weasyprint - -### MacOS - -Error: -``` -OSError: cannot load library 'pango-1.0-0': dlopen(pango-1.0-0, 0x0002): tried: 'pango-1.0-0' (no such file), '/System/Volumes/Preboot/Cryptexes/OSpango-1.0-0' (no such file), '/Users/yakser/.pyenv/versions/3.11.9/lib/pango-1.0-0' (no such file), '/System/Volumes/Preboot/Cryptexes/OS/Users/yakser/.pyenv/versions/3.11.9/lib/pango-1.0-0' (no such file), '/opt/homebrew/lib/pango-1.0-0' (no such file), '/System/Volumes/Preboot/Cryptexes/OS/opt/homebrew/lib/pango-1.0-0' (no such file), '/usr/lib/pango-1.0-0' (no such file, not in dyld cache), 'pango-1.0-0' (no such file), '/usr/local/lib/pango-1.0-0' (no such file), '/usr/lib/pango-1.0-0' (no such file, not in dyld cache). Additionally, ctypes.util.find_library() did not manage to locate a library called 'pango-1.0-0' -``` - -Fix: - -```shell -brew install weasyprint -``` - -### Windows - -Error: +poetry install +poetry run python manage.py migrate +poetry run python manage.py runserver +poetry run python manage.py test ``` -OSError: cannot load library 'gobject-2.0-0': error 0x7e. Additionally, ctypes.util.find_library() did not manage to locate a library called 'gobject-2.0-0' -``` - -Fix: - -Go to [WeasyPrint docs](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#windows) step by step install dependencies. If the error persists, add the path to the windows environment variable: `C:\msys64\mingw64\bin` +## Документация -## [Docs](/docs/readme.md) +- [Навигация по документации](docs/readme.md) +- [Разработка](docs/development.md) +- [Архитектура](docs/architecture.md) +- [API](docs/api.md) +- [Инфраструктура и деплой](docs/devops-state.md) +- [Доменные модули](docs/modules/readme.md) diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..d20c5852 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,33 @@ +# API + +## Назначение + +TODO + +## Общие правила + +TODO + +## Аутентификация + +TODO + +## Ключевые сценарии + +TODO + +## Основные endpoint'ы + +TODO + +## Контракты + +TODO + +## Ограничения и особенности + +TODO + +## Swagger и Redoc + +TODO diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..d92259f9 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,33 @@ +# Архитектура + +## Назначение backend + +TODO + +## Общая схема + +TODO + +## Доменные приложения + +TODO + +## Слой API + +TODO + +## Бизнес-логика + +TODO + +## Фоновые задачи + +TODO + +## WebSockets + +TODO + +## Хранилища и внешние зависимости + +TODO diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..3ad55f5a --- /dev/null +++ b/docs/development.md @@ -0,0 +1,29 @@ +# Разработка + +## Требования + +TODO + +## Настройка окружения + +TODO + +## Локальный запуск + +TODO + +## Миграции + +TODO + +## Тесты + +TODO + +## Проверки качества + +TODO + +## Частые проблемы + +TODO diff --git a/docs/devops-state.md b/docs/devops-state.md new file mode 100644 index 00000000..9889b454 --- /dev/null +++ b/docs/devops-state.md @@ -0,0 +1,29 @@ +# Инфраструктура и деплой + +## Окружения + +TODO + +## Текущее состояние dev + +TODO + +## Текущее состояние prod + +TODO + +## Процесс релиза + +TODO + +## Rollback + +TODO + +## Операционные проверки + +TODO + +## Известные риски + +TODO diff --git a/docs/chats.md b/docs/modules/chats.md similarity index 75% rename from docs/chats.md rename to docs/modules/chats.md index 14ec4a4b..acd41526 100644 --- a/docs/chats.md +++ b/docs/modules/chats.md @@ -1,3 +1,4 @@ +# Chats # Документация по вебсокетам чатов ## Общая инфа @@ -86,10 +87,10 @@ class EventType(str, Enum): ```json { - "type": "set_offline", - "content": { - - } + "type": "set_offline", + "content": { + + } } ``` @@ -99,27 +100,27 @@ class EventType(str, Enum): ```json { - "type": "new_message", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - "message": {{string}}, - "reply_to": number | null - } + "type": "new_message", + "content": { + "chat_type": {{"direct" | "project"}}, + "chat_id": {{"id1"_"id2"}}, // например: 1_2 + "message": {{string}}, + "reply_to": number | null + } } ``` -![New message event](img/event_new_message.png "New message event") +![New message event](../img/event_new_message.png "New message event") ##### EventType.TYPING ```json { - "type": "typing", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - } + "type": "typing", + "content": { + "chat_type": {{"direct" | "project"}}, + "chat_id": {{"id1"_"id2"}}, // например: 1_2 + } } ``` @@ -127,12 +128,12 @@ class EventType(str, Enum): ```json { - "type": "typing", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - "message_id": {{number}} - } + "type": "typing", + "content": { + "chat_type": {{"direct" | "project"}}, + "chat_id": {{"id1"_"id2"}}, // например: 1_2 + "message_id": {{number}} + } } ``` @@ -140,12 +141,12 @@ class EventType(str, Enum): ```json { - "type": "typing", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - "message_id": {{number}} - } + "type": "typing", + "content": { + "chat_type": {{"direct" | "project"}}, + "chat_id": {{"id1"_"id2"}}, // например: 1_2 + "message_id": {{number}} + } } ``` @@ -153,12 +154,12 @@ class EventType(str, Enum): ```json { - "type": "edit_message", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - "message_id": {{number}}, - "message": {{string}} - } + "type": "edit_message", + "content": { + "chat_type": {{"direct" | "project"}}, + "chat_id": {{"id1"_"id2"}}, // например: 1_2 + "message_id": {{number}}, + "message": {{string}} + } } ``` diff --git a/docs/modules/core.md b/docs/modules/core.md new file mode 100644 index 00000000..9fbc65bf --- /dev/null +++ b/docs/modules/core.md @@ -0,0 +1,3 @@ +# Core + +TODO diff --git a/docs/modules/courses.md b/docs/modules/courses.md new file mode 100644 index 00000000..dcee4621 --- /dev/null +++ b/docs/modules/courses.md @@ -0,0 +1,3 @@ +# Courses + +TODO diff --git a/docs/modules/events.md b/docs/modules/events.md new file mode 100644 index 00000000..48df748a --- /dev/null +++ b/docs/modules/events.md @@ -0,0 +1,3 @@ +# Events + +TODO diff --git a/docs/modules/feed.md b/docs/modules/feed.md new file mode 100644 index 00000000..129b81c7 --- /dev/null +++ b/docs/modules/feed.md @@ -0,0 +1,3 @@ +# Feed + +TODO diff --git a/docs/modules/files.md b/docs/modules/files.md new file mode 100644 index 00000000..7b6f7e2d --- /dev/null +++ b/docs/modules/files.md @@ -0,0 +1,3 @@ +# Files + +TODO diff --git a/docs/modules/industries.md b/docs/modules/industries.md new file mode 100644 index 00000000..99ed5524 --- /dev/null +++ b/docs/modules/industries.md @@ -0,0 +1,3 @@ +# Industries + +TODO diff --git a/docs/modules/invites.md b/docs/modules/invites.md new file mode 100644 index 00000000..dcca3981 --- /dev/null +++ b/docs/modules/invites.md @@ -0,0 +1,3 @@ +# Invites + +TODO diff --git a/docs/modules/mailing.md b/docs/modules/mailing.md new file mode 100644 index 00000000..3efbc016 --- /dev/null +++ b/docs/modules/mailing.md @@ -0,0 +1,3 @@ +# Mailing + +TODO diff --git a/docs/modules/metrics.md b/docs/modules/metrics.md new file mode 100644 index 00000000..b4301618 --- /dev/null +++ b/docs/modules/metrics.md @@ -0,0 +1,3 @@ +# Metrics + +TODO diff --git a/docs/modules/news.md b/docs/modules/news.md new file mode 100644 index 00000000..67e83aec --- /dev/null +++ b/docs/modules/news.md @@ -0,0 +1,3 @@ +# News + +TODO diff --git a/docs/modules/partner-programs.md b/docs/modules/partner-programs.md new file mode 100644 index 00000000..575b483b --- /dev/null +++ b/docs/modules/partner-programs.md @@ -0,0 +1,3 @@ +# Partner Programs + +TODO diff --git a/docs/modules/project-rates.md b/docs/modules/project-rates.md new file mode 100644 index 00000000..519541ea --- /dev/null +++ b/docs/modules/project-rates.md @@ -0,0 +1,3 @@ +# Project Rates + +TODO diff --git a/docs/modules/projects.md b/docs/modules/projects.md new file mode 100644 index 00000000..ba1203cb --- /dev/null +++ b/docs/modules/projects.md @@ -0,0 +1,3 @@ +# Projects + +TODO diff --git a/docs/modules/readme.md b/docs/modules/readme.md new file mode 100644 index 00000000..aab696ed --- /dev/null +++ b/docs/modules/readme.md @@ -0,0 +1,50 @@ +# Доменные модули + +## Пользователи + +- [users](users.md) + +## Проекты + +- [projects](projects.md) + +## Партнерские программы + +- [partner programs](partner-programs.md) + +## Курсы + +- [courses](courses.md) + +## Чаты + +- [chats](chats.md) + +## Вакансии + +- [vacancy](vacancy.md) + +## Лента и новости + +- [feed](feed.md) +- [news](news.md) + +## События + +- [events](events.md) + +## Файлы + +- [files](files.md) + +## Оценки проектов + +- [project rates](project-rates.md) + +## Прочие модули + +- [core](core.md) +- [industries](industries.md) +- [invites](invites.md) +- [mailing](mailing.md) +- [metrics](metrics.md) diff --git a/docs/modules/users.md b/docs/modules/users.md new file mode 100644 index 00000000..12d33799 --- /dev/null +++ b/docs/modules/users.md @@ -0,0 +1,3 @@ +# Users + +TODO diff --git a/docs/modules/vacancy.md b/docs/modules/vacancy.md new file mode 100644 index 00000000..5f396020 --- /dev/null +++ b/docs/modules/vacancy.md @@ -0,0 +1,3 @@ +# Vacancy + +TODO diff --git a/docs/readme.md b/docs/readme.md index 054001fe..33f6225b 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -1,7 +1,28 @@ -# Документация +# Документация Procollab Backend -## REST API -- [swagger](https://api.procollab.ru/swagger) -- [redoc](https://api.procollab.ru/redoc) +## Быстрый вход -## [WebSockets для чатов](/docs/chats.md) +- [README проекта](../README.md) + +## API + +- [Описание API](api.md) +- [Swagger](https://api.procollab.ru/swagger) +- [Redoc](https://api.procollab.ru/redoc) + +## Разработка + +- [Инструкция для разработчиков](development.md) + +## Архитектура + +- [Обзор архитектуры](architecture.md) +- [Доменные модули](modules/readme.md) + +## Инфраструктура и деплой + +- [DevOps state](devops-state.md) + +## WebSockets + +- [Чаты](modules/chats.md) From 53d610cf876d3b3171f88b9d504ede57705aa951 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 8 May 2026 11:33:37 +0500 Subject: [PATCH 2/4] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D0=BD=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=20=D0=BA=D1=83=D1=80=D1=81=D0=BE=D0=B2:=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=82=D0=B0=D1=85=20=D0=BF=D1=80=D0=BF=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F,?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=81=D1=87=D1=91=D1=82=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=BE=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Django=20?= =?UTF-8?q?admin,=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=8F=D0=B2=D0=BD=D0=B0=D1=8F=20=D1=81=D0=B5=D1=80=D0=B8=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20response=20payload?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20read=20API=20=D0=BA=D1=83=D1=80=D1=81?= =?UTF-8?q?=D0=BE=D0=B2.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20regression-=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- courses/admin_config/answers.py | 27 ++++++++++++++++++- courses/admin_config/forms.py | 2 ++ courses/api/response.py | 12 +++++++++ courses/api/views/course_read.py | 30 ++++++++++++--------- courses/api/views/lesson_read.py | 10 ++++--- courses/services/answers.py | 8 +++--- courses/tests/test_answers.py | 43 ++++++++++++++++++++++++++++++ courses/tests/test_api_extended.py | 16 +++++++++++ 8 files changed, 127 insertions(+), 21 deletions(-) create mode 100644 courses/api/response.py diff --git a/courses/admin_config/answers.py b/courses/admin_config/answers.py index df61a79d..b0824518 100644 --- a/courses/admin_config/answers.py +++ b/courses/admin_config/answers.py @@ -1,10 +1,25 @@ from django.contrib import admin -from courses.models import UserTaskAnswer, UserTaskAnswerFile, UserTaskAnswerOption +from courses.models import ( + CourseTaskCheckType, + UserTaskAnswer, + UserTaskAnswerFile, + UserTaskAnswerOption, +) +from courses.services.progress import recalculate_user_progresses_for_lesson from .inlines import UserTaskAnswerFileInline, UserTaskAnswerOptionInline +REVIEW_PROGRESS_FIELDS = { + "status", + "is_correct", + "review_comment", + "reviewed_by", + "reviewed_at", +} + + @admin.register(UserTaskAnswer) class UserTaskAnswerAdmin(admin.ModelAdmin): list_display = ( @@ -44,6 +59,16 @@ class UserTaskAnswerAdmin(admin.ModelAdmin): ) inlines = [UserTaskAnswerOptionInline, UserTaskAnswerFileInline] + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + + changed_fields = set(getattr(form, "changed_data", []) or []) + if ( + obj.task.check_type == CourseTaskCheckType.WITH_REVIEW + and changed_fields & REVIEW_PROGRESS_FIELDS + ): + recalculate_user_progresses_for_lesson(obj.user, obj.task.lesson) + @admin.register(UserTaskAnswerOption) class UserTaskAnswerOptionAdmin(admin.ModelAdmin): diff --git a/courses/admin_config/forms.py b/courses/admin_config/forms.py index 5028669c..f8936a21 100644 --- a/courses/admin_config/forms.py +++ b/courses/admin_config/forms.py @@ -117,6 +117,8 @@ def clean(self): "image_upload", "В поле изображения можно загрузить только файл изображения.", ) + # TODO: убрать временные флаги, когда upload -> UserFile будет вынесен + # в явный admin/service слой до запуска model validation. self.instance._has_pending_image_upload = bool(image_upload) self.instance._has_pending_attachment_upload = bool(attachment_upload) return cleaned_data diff --git a/courses/api/response.py b/courses/api/response.py new file mode 100644 index 00000000..7177091e --- /dev/null +++ b/courses/api/response.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + + +def serialize_response( + serializer_class: type[serializers.Serializer], + payload, + *, + many: bool = False, +): + serializer = serializer_class(data=payload, many=many) + serializer.is_valid(raise_exception=True) + return serializer.data diff --git a/courses/api/views/course_read.py b/courses/api/views/course_read.py index 3d91ada6..32e06bad 100644 --- a/courses/api/views/course_read.py +++ b/courses/api/views/course_read.py @@ -5,6 +5,7 @@ CourseDetailSerializer, CourseStructureSerializer, ) +from courses.api.response import serialize_response from courses.queries import ( build_course_detail_payload, build_course_list_payload, @@ -17,29 +18,32 @@ class CourseListAPIView(AuthenticatedCourseAPIView): def get(self, request): - serializer = CourseCardSerializer( - data=build_course_list_payload(request.user), - many=True, + return Response( + serialize_response( + CourseCardSerializer, + build_course_list_payload(request.user), + many=True, + ) ) - serializer.is_valid(raise_exception=True) - return Response(serializer.data) class CourseDetailAPIView(AuthenticatedCourseAPIView): def get(self, request, pk: int): - serializer = CourseDetailSerializer( - data=build_course_detail_payload(request.user, pk) + return Response( + serialize_response( + CourseDetailSerializer, + build_course_detail_payload(request.user, pk), + ) ) - serializer.is_valid(raise_exception=True) - return Response(serializer.data) class CourseStructureAPIView(AuthenticatedCourseAPIView): def get(self, request, pk: int): - serializer = CourseStructureSerializer( - data=build_course_structure_payload(request.user, pk) + return Response( + serialize_response( + CourseStructureSerializer, + build_course_structure_payload(request.user, pk), + ) ) - serializer.is_valid(raise_exception=True) - return Response(serializer.data) diff --git a/courses/api/views/lesson_read.py b/courses/api/views/lesson_read.py index 2871fba8..6490f202 100644 --- a/courses/api/views/lesson_read.py +++ b/courses/api/views/lesson_read.py @@ -1,5 +1,6 @@ from rest_framework.response import Response +from courses.api.response import serialize_response from courses.api.serializers import LessonDetailSerializer from courses.queries import build_lesson_detail_payload @@ -9,8 +10,9 @@ class LessonDetailAPIView(AuthenticatedCourseAPIView): def get(self, request, pk: int): - serializer = LessonDetailSerializer( - data=build_lesson_detail_payload(request.user, pk) + return Response( + serialize_response( + LessonDetailSerializer, + build_lesson_detail_payload(request.user, pk), + ) ) - serializer.is_valid(raise_exception=True) - return Response(serializer.data) diff --git a/courses/services/answers.py b/courses/services/answers.py index c3289819..7394824d 100644 --- a/courses/services/answers.py +++ b/courses/services/answers.py @@ -61,7 +61,7 @@ def _resolve_task_options( return options -def _resolve_user_files(file_ids: list[str]) -> list[UserFile]: +def _resolve_user_files(user, file_ids: list[str]) -> list[UserFile]: if not file_ids: return [] @@ -69,7 +69,7 @@ def _resolve_user_files(file_ids: list[str]) -> list[UserFile]: if len(unique_ids) != len(file_ids): raise ValidationError({"file_ids": "Переданы дублирующиеся файлы."}) - files = list(UserFile.objects.filter(pk__in=unique_ids)) + files = list(UserFile.objects.filter(pk__in=unique_ids, user=user)) 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: @@ -305,11 +305,12 @@ def _validate_question_task(task: CourseTask) -> None: def _resolve_question_payload( + user, task: CourseTask, payload: TaskAnswerSubmitPayload, ) -> tuple[str, list[CourseTaskOption], list[UserFile]]: selected_options = _resolve_task_options(task, payload.option_ids) - selected_files = _resolve_user_files(payload.file_ids) + selected_files = _resolve_user_files(user, payload.file_ids) _validate_payload_by_answer_type( task, payload, @@ -348,6 +349,7 @@ def _submit_question_answer( ) -> SubmitAnswerResult: _validate_question_task(task) normalized_text, selected_options, selected_files = _resolve_question_payload( + user, task, payload, ) diff --git a/courses/tests/test_answers.py b/courses/tests/test_answers.py index a7e59497..9863ba77 100644 --- a/courses/tests/test_answers.py +++ b/courses/tests/test_answers.py @@ -1,5 +1,11 @@ +from types import SimpleNamespace + +from django.contrib import admin from django.test import TestCase +from django.test import RequestFactory +from django.utils import timezone +from courses.admin_config.answers import UserTaskAnswerAdmin from courses.models import UserTaskAnswer, UserTaskAnswerStatus from courses.services.answers import TaskAnswerSubmitPayload, submit_user_task_answer @@ -68,3 +74,40 @@ def test_submit_text_question_with_review_blocks_continue(self): self.assertIsNone(answer.is_correct) self.assertFalse(result.can_continue) self.assertIsNone(result.next_task_id) + + def test_admin_review_recalculates_progress_after_accept(self): + reviewer = create_user(prefix="reviewer") + question_task = create_text_question_task( + self.lesson, + order=1, + check_type="with_review", + ) + submit_user_task_answer( + self.user, + question_task, + TaskAnswerSubmitPayload(answer_text="ok"), + ) + answer = UserTaskAnswer.objects.get(user=self.user, task=question_task) + request = RequestFactory().post("/") + request.user = reviewer + form = SimpleNamespace( + changed_data=["status", "is_correct", "reviewed_by", "reviewed_at"] + ) + + answer.status = UserTaskAnswerStatus.ACCEPTED + answer.is_correct = True + answer.reviewed_by = reviewer + answer.reviewed_at = timezone.now() + UserTaskAnswerAdmin(UserTaskAnswer, admin.site).save_model( + request, + answer, + form, + change=True, + ) + + lesson_progress = self.lesson.user_progresses.get(user=self.user) + module_progress = self.module.user_progresses.get(user=self.user) + course_progress = self.course.user_progresses.get(user=self.user) + self.assertEqual(lesson_progress.percent, 100) + self.assertEqual(module_progress.percent, 100) + self.assertEqual(course_progress.percent, 100) diff --git a/courses/tests/test_api_extended.py b/courses/tests/test_api_extended.py index 0598ff37..cbae18c4 100644 --- a/courses/tests/test_api_extended.py +++ b/courses/tests/test_api_extended.py @@ -172,6 +172,16 @@ def test_files_and_text_and_files_flow_validates_payload_and_completes_course(se "vnd.openxmlformats-officedocument.presentationml.presentation" ), ) + other_user = create_user(prefix="other-file-owner") + other_user_file = create_user_file( + other_user, + name="foreign-model", + extension="xlsx", + mime_type=( + "application/" + "vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) files_task = create_files_question_task( lesson, @@ -194,6 +204,11 @@ def test_files_and_text_and_files_flow_validates_payload_and_completes_course(se {"file_ids": ["https://cdn.example.com/missing/file.pdf"]}, format="json", ) + foreign_file_response = self.client.post( + f"/courses/tasks/{files_task.id}/answer/", + {"file_ids": [other_user_file.pk]}, + format="json", + ) files_response = self.client.post( f"/courses/tasks/{files_task.id}/answer/", {"file_ids": [answer_file_1.pk]}, @@ -216,6 +231,7 @@ def test_files_and_text_and_files_flow_validates_payload_and_completes_course(se course_detail = self.client.get(f"/courses/{course.id}/").json() self.assertEqual(invalid_file_response.status_code, 400) + self.assertEqual(foreign_file_response.status_code, 400) self.assertEqual(files_response.status_code, 200) self.assertTrue(files_response.json()["can_continue"]) self.assertEqual(invalid_text_and_files_response.status_code, 400) From e4cdf8543d31bf76e0bb30b4bd8af42a747b7f5a Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 8 May 2026 11:44:55 +0500 Subject: [PATCH 3/4] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D0=BA=D1=83=D1=80=D1=81=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/courses.md | 143 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/docs/modules/courses.md b/docs/modules/courses.md index dcee4621..50722453 100644 --- a/docs/modules/courses.md +++ b/docs/modules/courses.md @@ -1,3 +1,144 @@ # Courses -TODO +## Назначение + +Курсы отвечают за обучение пользователей внутри Procollab: список курсов, +доступность, структуру курса, прохождение уроков, ответы на задания, прогресс +и экспорт результатов. + +## Статус модуля + +Модуль находится в хорошем состоянии и может использоваться как ориентир для +рефакторинга других частей backend. + +## Основные возможности + +- просмотр списка доступных курсов; +- просмотр карточки курса; +- просмотр структуры курса: модули, уроки, задания; +- контроль доступности курса, модуля, урока и задания; +- отправка ответов на задания; +- поддержка заданий с ручной проверкой; +- пересчет прогресса пользователя; +- экспорт результатов курса из Django admin. + +## Архитектура + +- `courses/models/` - модели курса, контента, ответов и прогресса. +- `courses/api/views/` - HTTP endpoints. +- `courses/api/serializers/` - request и response contracts. +- `courses/queries/` - сборка read payload для API. +- `courses/services/` - бизнес-логика: доступы, ответы, прогресс, экспорт. +- `courses/admin_config/` - настройка Django admin. +- `courses/tests/` - тесты модуля. + +## Основные сущности + +- `Course` - курс. +- `CourseModule` - модуль курса. +- `CourseLesson` - урок. +- `CourseTask` - задание. +- `CourseTaskOption` - вариант ответа. +- `UserTaskAnswer` - ответ пользователя. +- `UserCourseProgress` - прогресс по курсу. +- `UserModuleProgress` - прогресс по модулю. +- `UserLessonProgress` - прогресс по уроку. + +## API + +- `GET /courses/` - список курсов. +- `GET /courses//` - детали курса. +- `GET /courses//structure/` - структура курса. +- `POST /courses//visit/` - отметка посещения курса. +- `GET /courses/lessons//` - детали урока. +- `POST /courses/tasks//answer/` - отправка ответа на задание. + +## Основные сценарии + +### 1. Выбор курса + +Пользователь открывает список курсов и видит доступные ему карточки. Для каждой +карточки отображаются статус, период доступности, состояние действия +(`start`, `continue`, `lock`) и текущий прогресс. + +Доступность курса зависит от: + +- статуса курса; +- типа доступа; +- участия пользователя в партнерской программе; +- дат начала и окончания курса; +- факта завершения курса. + +### 2. Выбор модуля + +После открытия курса пользователь видит структуру из модулей. Модуль доступен, +если доступен сам курс, модуль опубликован, наступила дата старта модуля и +предыдущий модуль завершен. + +Для каждого модуля отображаются уроки, прогресс и состояние доступности. + +### 3. Выбор урока + +Пользователь может открыть только доступный опубликованный урок. Урок доступен, +если доступен его модуль и предыдущий урок завершен. + +Урок состоит из заданий. Поддерживаются два основных типа заданий: + +- информационные задания - пользователь должен ознакомиться с материалом; +- задания с ответом - пользователь должен отправить текст, выбрать вариант или + прикрепить файл. + +Для заданий с ответом поддерживаются разные типы ответа: + +- текст; +- один вариант; +- несколько вариантов; +- файл; +- текст и файл. + +### 4. Прохождение урока + +Пользователь проходит задания урока по порядку. Следующее задание открывается +после завершения текущего. + +После отправки ответа система обновляет: + +- прогресс урока; +- прогресс модуля; +- прогресс курса; +- текущее задание пользователя. + +Если урок, модуль или курс завершены полностью, их прогресс становится 100%. + +### 5. Проверка ответов + +Задания могут проверяться автоматически или вручную. + +При автоматической проверке результат определяется сразу после отправки ответа. +Если ответ принят, пользователь может продолжить прохождение. + +При ручной проверке ответ получает статус `pending_review`. Администратор +проверяет ответ в Django admin и выставляет итоговый статус. После изменения +review-полей прогресс пользователя пересчитывается автоматически. + +## Ограничения и правила + +- `file_ids` в ответах могут ссылаться только на файлы текущего пользователя. +- Доступ к урокам и заданиям зависит от порядка прохождения. +- Для опубликованных choice-заданий должен быть корректный набор правильных + вариантов. +- Upload flow в Django admin использует временные флаги `_has_pending_*`; это + зафиксированный технический долг. + +## Тесты + +Тесты покрывают: + +- API flow; +- доступность курсов и уроков; +- отправку ответов; +- прогресс; +- learning flow; +- экспорт результатов; +- ручную проверку через admin; +- ограничения на прикрепление файлов. From 4efbc7d3bd28341512a1ade768f3a13784258073 Mon Sep 17 00:00:00 2001 From: Toksi Date: Tue, 12 May 2026 13:34:02 +0500 Subject: [PATCH 4/4] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=81=D1=82=D0=B2=D1=83?= =?UTF-8?q?=D1=8E=D1=89=D0=B0=D1=8F=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BA=D1=83=D1=80=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- courses/tests/helpers.py | 28 +++ courses/tests/test_access.py | 328 ++++++++++++++++++++++++++- courses/tests/test_answers.py | 294 +++++++++++++++++++++++- courses/tests/test_content_models.py | 280 +++++++++++++++++++++++ courses/tests/test_learning_flow.py | 14 +- courses/tests/test_progress.py | 313 ++++++++++++++++++++++++- docs/modules/courses.md | 62 ++++- 7 files changed, 1285 insertions(+), 34 deletions(-) create mode 100644 courses/tests/test_content_models.py diff --git a/courses/tests/helpers.py b/courses/tests/helpers.py index fdc5dbe7..b5d428b7 100644 --- a/courses/tests/helpers.py +++ b/courses/tests/helpers.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from datetime import date, timedelta from uuid import uuid4 @@ -25,6 +26,14 @@ from users.models import CustomUser +@dataclass(frozen=True) +class CourseTestContext: + user: CustomUser + course: Course + module: CourseModule + lesson: CourseLesson + + def unique_suffix() -> str: return uuid4().hex[:8] @@ -122,6 +131,25 @@ def create_lesson( ) +def create_course_context( + *, + user_prefix: str = "courses-test", + course_title: str = "Course", + module_title: str = "Module", + lesson_title: str = "Lesson", +) -> CourseTestContext: + user = create_user(prefix=user_prefix) + course = create_course(title=course_title) + module = create_module(course, title=module_title) + lesson = create_lesson(module, title=lesson_title) + return CourseTestContext( + user=user, + course=course, + module=module, + lesson=lesson, + ) + + def create_informational_task( lesson: CourseLesson, *, diff --git a/courses/tests/test_access.py b/courses/tests/test_access.py index bf98eb51..87a70bfd 100644 --- a/courses/tests/test_access.py +++ b/courses/tests/test_access.py @@ -1,12 +1,75 @@ +from datetime import date, datetime, timedelta +from types import SimpleNamespace + +from django.contrib.auth.models import AnonymousUser from django.test import TestCase -from courses.models import CourseAccessType -from courses.services.access import resolve_course_availability +from courses.models import ( + CourseAccessType, + CourseContentStatus, + CourseLessonContentStatus, + CourseModuleContentStatus, + ProgressStatus, +) +from courses.services.access import ( + ACTION_CONTINUE, + ACTION_LOCK, + ACTION_START, + is_course_completed, + is_lesson_available, + is_module_available, + is_user_program_member, + moscow_today, + resolve_course_action_state, + resolve_course_availability, + resolve_course_card_state, + resolve_course_date_label, +) -from .helpers import add_program_member, create_course, create_partner_program, create_user +from .helpers import ( + add_program_member, + create_course, + create_lesson, + create_module, + create_partner_program, + create_user, +) class CourseAccessServiceTests(TestCase): + def assert_course_unavailable(self, course, user, reason): + availability = resolve_course_availability(course, user) + + self.assertFalse(availability.is_available) + self.assertEqual(availability.reason, reason) + + def test_moscow_today_accepts_naive_datetime(self): + self.assertEqual( + moscow_today(datetime(2026, 5, 12, 12, 0)), + date(2026, 5, 12), + ) + + def test_course_completed_by_status(self): + course = create_course() + course.status = CourseContentStatus.COMPLETED + course.is_completed = True + + self.assertTrue(is_course_completed(course, today=date(2026, 5, 12))) + + def test_course_completed_by_flag(self): + course = create_course() + course.is_completed = True + + self.assertTrue(is_course_completed(course, today=date(2026, 5, 12))) + + def test_course_completed_by_end_date(self): + today = date(2026, 5, 12) + course = create_course() + course.start_date = today - timedelta(days=10) + course.end_date = today - timedelta(days=1) + + self.assertTrue(is_course_completed(course, today=today)) + def test_all_users_course_available_for_authenticated_user(self): user = create_user() course = create_course(access_type=CourseAccessType.ALL_USERS) @@ -15,6 +78,35 @@ def test_all_users_course_available_for_authenticated_user(self): self.assertTrue(availability.is_available) + def test_course_availability_blocks_unauthenticated_user(self): + course = create_course() + + self.assert_course_unavailable( + course, + AnonymousUser(), + "authentication_required", + ) + + def test_course_availability_blocks_draft_course(self): + user = create_user() + course = create_course(status=CourseContentStatus.DRAFT) + + self.assert_course_unavailable(course, user, "draft") + + def test_course_availability_blocks_completed_course(self): + user = create_user() + course = create_course() + course.status = CourseContentStatus.COMPLETED + course.is_completed = True + + self.assert_course_unavailable(course, user, "completed") + + def test_course_availability_blocks_subscription_stub(self): + user = create_user() + course = create_course(access_type=CourseAccessType.SUBSCRIPTION_STUB) + + self.assert_course_unavailable(course, user, "subscription_required") + def test_program_members_course_blocked_for_outsider(self): user = create_user(prefix="outsider") program = create_partner_program() @@ -23,10 +115,7 @@ def test_program_members_course_blocked_for_outsider(self): partner_program=program, ) - availability = resolve_course_availability(course, user) - - self.assertFalse(availability.is_available) - self.assertEqual(availability.reason, "not_program_member") + self.assert_course_unavailable(course, user, "not_program_member") def test_program_members_course_available_for_member(self): user = create_user(prefix="member") @@ -40,3 +129,228 @@ def test_program_members_course_available_for_member(self): availability = resolve_course_availability(course, user) self.assertTrue(availability.is_available) + + def test_is_user_program_member_handles_anonymous_user_and_course_without_program(self): + user = create_user() + course = create_course() + + self.assertFalse(is_user_program_member(course, AnonymousUser())) + self.assertFalse(is_user_program_member(course, user)) + + def test_course_action_state_starts_without_progress(self): + user = create_user() + course = create_course() + + self.assertEqual(resolve_course_action_state(course, user), ACTION_START) + + def test_course_action_state_starts_for_not_started_progress(self): + user = create_user() + course = create_course() + + action_state = resolve_course_action_state( + course, + user, + progress=SimpleNamespace(status=ProgressStatus.NOT_STARTED), + ) + + self.assertEqual(action_state, ACTION_START) + + def test_course_action_state_continues_for_in_progress(self): + user = create_user() + course = create_course() + + action_state = resolve_course_action_state( + course, + user, + progress=SimpleNamespace(status=ProgressStatus.IN_PROGRESS), + ) + + self.assertEqual(action_state, ACTION_CONTINUE) + + def test_course_action_state_locks_completed_progress(self): + user = create_user() + course = create_course() + + action_state = resolve_course_action_state( + course, + user, + progress=SimpleNamespace(status=ProgressStatus.COMPLETED), + ) + + self.assertEqual(action_state, ACTION_LOCK) + + def test_course_action_state_locks_unavailable_course(self): + course = create_course() + + self.assertEqual( + resolve_course_action_state(course, AnonymousUser()), + ACTION_LOCK, + ) + + def test_course_date_label_for_indefinite_course(self): + course = create_course() + + self.assertEqual( + resolve_course_date_label(course, today=date(2026, 5, 12)), + "бессрочно", + ) + + def test_course_date_label_for_future_course(self): + today = date(2026, 5, 12) + course = create_course() + course.start_date = today + timedelta(days=1) + course.end_date = today + timedelta(days=10) + + self.assertEqual( + resolve_course_date_label(course, today=today), + "13.05.26 - 22.05.26", + ) + + def test_course_date_label_for_active_course(self): + today = date(2026, 5, 12) + course = create_course() + course.start_date = today - timedelta(days=1) + course.end_date = today + timedelta(days=10) + + self.assertEqual( + resolve_course_date_label(course, today=today), + "доступен до 22.05.2026", + ) + + def test_course_date_label_for_completed_course(self): + course = create_course() + course.status = CourseContentStatus.COMPLETED + course.is_completed = True + + self.assertEqual( + resolve_course_date_label(course, today=date(2026, 5, 12)), + "курс завершен", + ) + + def test_course_card_state_combines_availability_action_and_date_label(self): + user = create_user() + course = create_course() + + state = resolve_course_card_state(course, user) + + self.assertTrue(state.is_available) + self.assertEqual(state.action_state, ACTION_START) + self.assertEqual(state.date_label, "бессрочно") + + def test_module_available_for_available_course_published_module_and_completed_previous(self): + course = create_course() + module = create_module(course) + + self.assertTrue( + is_module_available( + module, + course_available=True, + previous_module_completed=True, + today=date(2026, 5, 12), + ) + ) + + def test_module_unavailable_when_course_is_unavailable(self): + course = create_course() + module = create_module(course) + + self.assertFalse( + is_module_available( + module, + course_available=False, + previous_module_completed=True, + today=date(2026, 5, 12), + ) + ) + + def test_module_unavailable_when_module_is_draft(self): + course = create_course() + module = create_module(course, status=CourseModuleContentStatus.DRAFT) + + self.assertFalse( + is_module_available( + module, + course_available=True, + previous_module_completed=True, + today=date(2026, 5, 12), + ) + ) + + def test_module_unavailable_before_start_date(self): + today = date(2026, 5, 12) + course = create_course() + module = create_module(course, start_date_value=today + timedelta(days=1)) + + self.assertFalse( + is_module_available( + module, + course_available=True, + previous_module_completed=True, + today=today, + ) + ) + + def test_module_unavailable_when_previous_module_is_not_completed(self): + course = create_course() + module = create_module(course) + + self.assertFalse( + is_module_available( + module, + course_available=True, + previous_module_completed=False, + today=date(2026, 5, 12), + ) + ) + + def test_lesson_available_for_available_module_published_lesson_and_completed_previous(self): + course = create_course() + module = create_module(course) + lesson = create_lesson(module) + + self.assertTrue( + is_lesson_available( + lesson, + module_available=True, + previous_lesson_completed=True, + ) + ) + + def test_lesson_unavailable_when_module_is_unavailable(self): + course = create_course() + module = create_module(course) + lesson = create_lesson(module) + + self.assertFalse( + is_lesson_available( + lesson, + module_available=False, + previous_lesson_completed=True, + ) + ) + + def test_lesson_unavailable_when_lesson_is_draft(self): + course = create_course() + module = create_module(course) + lesson = create_lesson(module, status=CourseLessonContentStatus.DRAFT) + + self.assertFalse( + is_lesson_available( + lesson, + module_available=True, + previous_lesson_completed=True, + ) + ) + + def test_lesson_unavailable_when_previous_lesson_is_not_completed(self): + course = create_course() + module = create_module(course) + lesson = create_lesson(module) + + self.assertFalse( + is_lesson_available( + lesson, + module_available=True, + previous_lesson_completed=False, + ) + ) diff --git a/courses/tests/test_answers.py b/courses/tests/test_answers.py index 9863ba77..60ff333b 100644 --- a/courses/tests/test_answers.py +++ b/courses/tests/test_answers.py @@ -1,30 +1,42 @@ from types import SimpleNamespace from django.contrib import admin +from django.core.exceptions import ValidationError from django.test import TestCase from django.test import RequestFactory from django.utils import timezone from courses.admin_config.answers import UserTaskAnswerAdmin -from courses.models import UserTaskAnswer, UserTaskAnswerStatus +from courses.models import ( + CourseTaskAnswerType, + CourseTaskOption, + UserTaskAnswer, + UserTaskAnswerFile, + UserTaskAnswerOption, + UserTaskAnswerStatus, +) +from courses.models.constants import DEFAULT_MAX_FILES_PER_ANSWER from courses.services.answers import TaskAnswerSubmitPayload, submit_user_task_answer from .helpers import ( - create_course, + create_choice_question_task, + create_course_context, + create_files_question_task, create_informational_task, - create_lesson, - create_module, + create_text_and_files_question_task, create_text_question_task, create_user, + create_user_file, ) class SubmitUserTaskAnswerTests(TestCase): def setUp(self): - self.user = create_user() - self.course = create_course() - self.module = create_module(self.course) - self.lesson = create_lesson(self.module) + context = create_course_context() + self.user = context.user + self.course = context.course + self.module = context.module + self.lesson = context.lesson def test_submit_informational_answer_creates_marker_and_allows_continue(self): info_task = create_informational_task(self.lesson, order=1) @@ -111,3 +123,269 @@ def test_admin_review_recalculates_progress_after_accept(self): self.assertEqual(lesson_progress.percent, 100) self.assertEqual(module_progress.percent, 100) self.assertEqual(course_progress.percent, 100) + + +class UserTaskAnswerModelValidationTests(TestCase): + def setUp(self): + context = create_course_context() + self.user = context.user + self.course = context.course + self.module = context.module + self.lesson = context.lesson + + def assertValidationErrorFields(self, error, expected_fields): + self.assertEqual( + set(error.exception.message_dict), + set(expected_fields), + ) + + def build_answer(self, task, **overrides): + values = { + "user": self.user, + "task": task, + } + values.update(overrides) + return UserTaskAnswer(**values) + + def test_informational_answer_forbids_text_review_fields_and_review_status(self): + reviewer = create_user(prefix="reviewer") + info_task = create_informational_task(self.lesson) + answer = self.build_answer( + task=info_task, + answer_text="not needed", + status=UserTaskAnswerStatus.ACCEPTED, + review_comment="checked", + reviewed_by=reviewer, + reviewed_at=timezone.now(), + ) + + with self.assertRaises(ValidationError) as error: + answer.full_clean() + + self.assertValidationErrorFields( + error, + ["answer_text", "status", "review_comment", "reviewed_by", "reviewed_at"], + ) + + def test_text_answers_require_text(self): + text_task = create_text_question_task(self.lesson, order=1) + text_and_files_task = create_text_and_files_question_task(self.lesson, order=2) + + for task in (text_task, text_and_files_task): + with self.subTest(answer_type=task.answer_type): + answer = self.build_answer(task, answer_text=" ") + + with self.assertRaises(ValidationError) as error: + answer.full_clean() + + self.assertValidationErrorFields(error, ["answer_text"]) + + def test_non_text_answers_forbid_text(self): + attachment = create_user_file(self.user) + files_task = create_files_question_task( + self.lesson, + attachment_file=attachment, + order=1, + ) + single_choice_task, _ = create_choice_question_task( + self.lesson, + title="Single choice", + order=2, + answer_type=CourseTaskAnswerType.SINGLE_CHOICE, + options=[("Correct", True), ("Wrong", False)], + ) + multiple_choice_task, _ = create_choice_question_task( + self.lesson, + title="Multiple choice", + order=3, + answer_type=CourseTaskAnswerType.MULTIPLE_CHOICE, + options=[("Correct 1", True), ("Correct 2", True), ("Wrong", False)], + ) + + for task in (files_task, single_choice_task, multiple_choice_task): + with self.subTest(answer_type=task.answer_type): + answer = self.build_answer( + task, + answer_text="not allowed", + ) + + with self.assertRaises(ValidationError) as error: + answer.full_clean() + + self.assertValidationErrorFields(error, ["answer_text"]) + + def test_pending_review_is_forbidden_for_task_without_review(self): + task = create_text_question_task(self.lesson) + answer = self.build_answer( + task=task, + answer_text="ok", + status=UserTaskAnswerStatus.PENDING_REVIEW, + ) + + with self.assertRaises(ValidationError) as error: + answer.full_clean() + + self.assertValidationErrorFields(error, ["status"]) + + def test_reviewed_by_and_reviewed_at_must_be_filled_together(self): + reviewer = create_user(prefix="reviewer") + task = create_text_question_task(self.lesson, check_type="with_review") + answer = self.build_answer( + task=task, + answer_text="ok", + reviewed_by=reviewer, + ) + + with self.assertRaises(ValidationError) as error: + answer.full_clean() + + self.assertValidationErrorFields(error, ["reviewed_by", "reviewed_at"]) + + def test_checked_answer_with_review_requires_reviewer(self): + task = create_text_question_task(self.lesson, check_type="with_review") + answer = self.build_answer( + task=task, + answer_text="ok", + status=UserTaskAnswerStatus.ACCEPTED, + ) + + with self.assertRaises(ValidationError) as error: + answer.full_clean() + + self.assertValidationErrorFields(error, ["reviewed_by", "reviewed_at"]) + + +class UserTaskAnswerOptionModelValidationTests(TestCase): + def setUp(self): + context = create_course_context() + self.user = context.user + self.course = context.course + self.module = context.module + self.lesson = context.lesson + + def test_selected_option_must_belong_to_answer_task(self): + first_task, _ = create_choice_question_task( + self.lesson, + title="First choice", + order=1, + answer_type=CourseTaskAnswerType.SINGLE_CHOICE, + options=[("A", True), ("B", False)], + ) + second_task, second_options = create_choice_question_task( + self.lesson, + title="Second choice", + order=2, + answer_type=CourseTaskAnswerType.SINGLE_CHOICE, + options=[("C", True), ("D", False)], + ) + answer = UserTaskAnswer.objects.create(user=self.user, task=first_task) + + selected_option = UserTaskAnswerOption( + answer=answer, + option=second_options[0], + ) + + with self.assertRaises(ValidationError) as error: + selected_option.full_clean() + + self.assertIn("option", error.exception.message_dict) + + def test_selected_option_is_allowed_only_for_choice_answers(self): + task = create_text_question_task(self.lesson) + answer = UserTaskAnswer.objects.create( + user=self.user, + task=task, + answer_text="ok", + ) + option = CourseTaskOption(task=task, order=1, text="Invalid option") + CourseTaskOption.objects.bulk_create([option]) + option.refresh_from_db() + + selected_option = UserTaskAnswerOption(answer=answer, option=option) + + with self.assertRaises(ValidationError) as error: + selected_option.full_clean() + + self.assertIn("answer", error.exception.message_dict) + + def test_single_choice_answer_allows_only_one_selected_option(self): + task, options = create_choice_question_task( + self.lesson, + title="Single choice", + order=1, + answer_type=CourseTaskAnswerType.SINGLE_CHOICE, + options=[("A", True), ("B", False)], + ) + answer = UserTaskAnswer.objects.create(user=self.user, task=task) + UserTaskAnswerOption.objects.create(answer=answer, option=options[0]) + + with self.assertRaises(ValidationError) as error: + UserTaskAnswerOption.objects.create(answer=answer, option=options[1]) + + self.assertIn("answer", error.exception.message_dict) + + +class UserTaskAnswerFileModelValidationTests(TestCase): + def setUp(self): + context = create_course_context() + self.user = context.user + self.course = context.course + self.module = context.module + self.lesson = context.lesson + + def test_answer_files_are_allowed_only_for_file_answer_types(self): + task = create_text_question_task(self.lesson) + answer = UserTaskAnswer.objects.create( + user=self.user, + task=task, + answer_text="ok", + ) + user_file = create_user_file(self.user) + answer_file = UserTaskAnswerFile(answer=answer, file=user_file) + + with self.assertRaises(ValidationError) as error: + answer_file.full_clean() + + self.assertIn("answer", error.exception.message_dict) + + def test_answer_file_save_fills_file_name_and_size(self): + attachment = create_user_file(self.user, name="task-template") + task = create_files_question_task( + self.lesson, + attachment_file=attachment, + ) + answer = UserTaskAnswer.objects.create(user=self.user, task=task) + user_file = create_user_file( + self.user, + name="solution", + extension="txt", + mime_type="text/plain", + size=2048, + ) + + answer_file = UserTaskAnswerFile.objects.create(answer=answer, file=user_file) + + self.assertEqual(answer_file.file_name, user_file.name) + self.assertEqual(answer_file.file_size, user_file.size) + + def test_answer_files_are_limited_per_answer(self): + attachment = create_user_file(self.user, name="task-template") + task = create_files_question_task( + self.lesson, + attachment_file=attachment, + ) + answer = UserTaskAnswer.objects.create(user=self.user, task=task) + existing_files = [ + UserTaskAnswerFile( + answer=answer, + file=create_user_file(self.user, name=f"solution-{index}"), + ) + for index in range(DEFAULT_MAX_FILES_PER_ANSWER) + ] + UserTaskAnswerFile.objects.bulk_create(existing_files) + extra_file = create_user_file(self.user, name="extra-solution") + + with self.assertRaises(ValidationError) as error: + UserTaskAnswerFile.objects.create(answer=answer, file=extra_file) + + self.assertIn("answer", error.exception.message_dict) diff --git a/courses/tests/test_content_models.py b/courses/tests/test_content_models.py new file mode 100644 index 00000000..c888c6f5 --- /dev/null +++ b/courses/tests/test_content_models.py @@ -0,0 +1,280 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from courses.models import ( + CourseModule, + CourseTask, + CourseTaskAnswerType, + CourseTaskCheckType, + CourseTaskContentStatus, + CourseTaskInformationalType, + CourseTaskKind, + CourseTaskOption, + CourseTaskQuestionType, +) + +from .helpers import ( + create_course_context, + create_informational_task, + create_text_question_task, + create_user_file, +) + + +class CourseContentModelValidationTests(TestCase): + def setUp(self): + context = create_course_context() + self.user = context.user + self.course = context.course + self.module = context.module + self.lesson = context.lesson + + def assertValidationErrorFields(self, error, expected_fields): + self.assertEqual( + set(error.exception.message_dict), + set(expected_fields), + ) + + def build_informational_task(self, informational_type, **overrides): + values = { + "lesson": self.lesson, + "title": "Informational task", + "status": CourseTaskContentStatus.DRAFT, + "task_kind": CourseTaskKind.INFORMATIONAL, + "informational_type": informational_type, + "order": 1, + } + values.update(overrides) + return CourseTask(**values) + + def build_question_task(self, question_type, **overrides): + values = { + "lesson": self.lesson, + "title": "Question task", + "status": CourseTaskContentStatus.DRAFT, + "task_kind": CourseTaskKind.QUESTION, + "question_type": question_type, + "answer_type": CourseTaskAnswerType.TEXT, + "check_type": CourseTaskCheckType.WITHOUT_REVIEW, + "order": 1, + } + values.update(overrides) + return CourseTask(**values) + + def test_module_avatar_must_be_image_file(self): + avatar = create_user_file( + self.user, + name="avatar", + extension="pdf", + mime_type="application/pdf", + ) + module = CourseModule( + course=self.course, + title="Invalid avatar module", + avatar_file=avatar, + start_date=self.module.start_date, + status=self.module.status, + order=2, + ) + + with self.assertRaises(ValidationError) as error: + module.full_clean() + + self.assertValidationErrorFields(error, ["avatar_file"]) + + def test_task_image_file_must_be_image(self): + image = create_user_file( + self.user, + name="image", + extension="pdf", + mime_type="application/pdf", + ) + task = self.build_question_task( + CourseTaskQuestionType.TEXT, + body_text="Question body", + image_file=image, + ) + + with self.assertRaises(ValidationError) as error: + task.full_clean() + + self.assertValidationErrorFields(error, ["image_file"]) + + def test_informational_video_text_requires_body_text_and_video_url(self): + task = self.build_informational_task(CourseTaskInformationalType.VIDEO_TEXT) + + with self.assertRaises(ValidationError) as error: + task.full_clean() + + self.assertValidationErrorFields(error, ["body_text", "video_url"]) + + def test_informational_text_requires_body_text(self): + task = self.build_informational_task(CourseTaskInformationalType.TEXT) + + with self.assertRaises(ValidationError) as error: + task.full_clean() + + self.assertValidationErrorFields(error, ["body_text"]) + + def test_informational_text_image_requires_body_text_and_image(self): + task = self.build_informational_task(CourseTaskInformationalType.TEXT_IMAGE) + + with self.assertRaises(ValidationError) as error: + task.full_clean() + + self.assertValidationErrorFields(error, ["body_text", "image_file"]) + + def test_pending_image_upload_satisfies_image_source_requirement(self): + task = self.build_informational_task( + CourseTaskInformationalType.TEXT_IMAGE, + body_text="Text with uploaded image", + ) + task._has_pending_image_upload = True + + task.full_clean() + + def test_question_image_text_requires_body_text_and_image(self): + task = self.build_question_task(CourseTaskQuestionType.IMAGE_TEXT) + + with self.assertRaises(ValidationError) as error: + task.full_clean() + + self.assertValidationErrorFields(error, ["body_text", "image_file"]) + + def test_question_video_requires_video_url(self): + task = self.build_question_task(CourseTaskQuestionType.VIDEO) + + with self.assertRaises(ValidationError) as error: + task.full_clean() + + self.assertValidationErrorFields(error, ["video_url"]) + + def test_question_image_requires_image(self): + task = self.build_question_task(CourseTaskQuestionType.IMAGE) + + with self.assertRaises(ValidationError) as error: + task.full_clean() + + self.assertValidationErrorFields(error, ["image_file"]) + + def test_question_text_file_requires_body_text_and_attachment(self): + task = self.build_question_task( + CourseTaskQuestionType.TEXT_FILE, + answer_type=CourseTaskAnswerType.FILES, + ) + + with self.assertRaises(ValidationError) as error: + task.full_clean() + + self.assertValidationErrorFields(error, ["body_text", "attachment_file"]) + + def test_pending_attachment_upload_satisfies_attachment_source_requirement(self): + task = self.build_question_task( + CourseTaskQuestionType.TEXT_FILE, + answer_type=CourseTaskAnswerType.FILES, + body_text="Attach file", + ) + task._has_pending_attachment_upload = True + + task.full_clean() + + def test_question_text_requires_body_text(self): + task = self.build_question_task(CourseTaskQuestionType.TEXT) + + with self.assertRaises(ValidationError) as error: + task.full_clean() + + self.assertValidationErrorFields(error, ["body_text"]) + + def test_published_choice_task_requires_correct_option(self): + task = CourseTask.objects.create( + lesson=self.lesson, + title="Choice question", + status=CourseTaskContentStatus.DRAFT, + task_kind=CourseTaskKind.QUESTION, + question_type=CourseTaskQuestionType.TEXT, + answer_type=CourseTaskAnswerType.MULTIPLE_CHOICE, + check_type=CourseTaskCheckType.WITHOUT_REVIEW, + body_text="Choose", + order=1, + ) + CourseTaskOption.objects.create(task=task, text="Wrong", order=1) + task.status = CourseTaskContentStatus.PUBLISHED + + with self.assertRaises(ValidationError) as error: + task.full_clean() + + self.assertValidationErrorFields(error, ["status"]) + + def test_published_single_choice_task_requires_one_correct_option(self): + task = CourseTask.objects.create( + lesson=self.lesson, + title="Single choice question", + status=CourseTaskContentStatus.DRAFT, + task_kind=CourseTaskKind.QUESTION, + question_type=CourseTaskQuestionType.TEXT, + answer_type=CourseTaskAnswerType.SINGLE_CHOICE, + check_type=CourseTaskCheckType.WITHOUT_REVIEW, + body_text="Choose", + order=1, + ) + CourseTaskOption.objects.bulk_create( + [ + CourseTaskOption(task=task, text="Correct 1", is_correct=True, order=1), + CourseTaskOption(task=task, text="Correct 2", is_correct=True, order=2), + ] + ) + task.status = CourseTaskContentStatus.PUBLISHED + + with self.assertRaises(ValidationError) as error: + task.full_clean() + + self.assertValidationErrorFields(error, ["status"]) + + def test_task_option_is_forbidden_for_informational_task(self): + task = create_informational_task(self.lesson) + option = CourseTaskOption(task=task, text="Invalid", order=1) + + with self.assertRaises(ValidationError) as error: + option.full_clean() + + self.assertValidationErrorFields(error, ["task"]) + + def test_task_option_is_allowed_only_for_choice_answer_types(self): + task = create_text_question_task(self.lesson) + option = CourseTaskOption(task=task, text="Invalid", order=1) + + with self.assertRaises(ValidationError) as error: + option.full_clean() + + self.assertValidationErrorFields(error, ["task"]) + + def test_single_choice_task_allows_only_one_correct_option(self): + task = CourseTask.objects.create( + lesson=self.lesson, + title="Single choice draft", + status=CourseTaskContentStatus.DRAFT, + task_kind=CourseTaskKind.QUESTION, + question_type=CourseTaskQuestionType.TEXT, + answer_type=CourseTaskAnswerType.SINGLE_CHOICE, + check_type=CourseTaskCheckType.WITHOUT_REVIEW, + body_text="Choose", + order=1, + ) + CourseTaskOption.objects.create( + task=task, + text="Correct 1", + is_correct=True, + order=1, + ) + option = CourseTaskOption( + task=task, + text="Correct 2", + is_correct=True, + order=2, + ) + + with self.assertRaises(ValidationError) as error: + option.full_clean() + + self.assertValidationErrorFields(error, ["is_correct"]) diff --git a/courses/tests/test_learning_flow.py b/courses/tests/test_learning_flow.py index 0c29838b..c7605b58 100644 --- a/courses/tests/test_learning_flow.py +++ b/courses/tests/test_learning_flow.py @@ -8,21 +8,19 @@ from courses.services.progress import recalculate_user_progresses_for_lesson from .helpers import ( - create_course, + create_course_context, create_informational_task, - create_lesson, - create_module, create_text_question_task, - create_user, ) class LearningFlowTests(TestCase): def setUp(self): - self.user = create_user() - self.course = create_course() - self.module = create_module(self.course) - self.lesson = create_lesson(self.module) + context = create_course_context() + self.user = context.user + self.course = context.course + self.module = context.module + self.lesson = context.lesson def test_informational_task_not_completed_before_explicit_submit(self): info_task = create_informational_task(self.lesson, order=1) diff --git a/courses/tests/test_progress.py b/courses/tests/test_progress.py index ce99f59c..ec8cd243 100644 --- a/courses/tests/test_progress.py +++ b/courses/tests/test_progress.py @@ -1,19 +1,330 @@ +from unittest import mock + +from django.db import IntegrityError +from django.utils import timezone from django.test import TestCase +from courses.models import ( + ProgressStatus, + UserTaskAnswer, + UserTaskAnswerStatus, +) +from courses.models.progress import ( + UserCourseProgress, + UserLessonProgress, + UserModuleProgress, +) from courses.services.progress import ( + build_progress_snapshot, build_progress_snapshot_from_percent, + percent_from_counts, percent_from_total_percent, + progress_payload, + recalculate_user_progresses_for_lesson, + status_from_percent, + touch_course_visit, + upsert_course_progress, + upsert_lesson_progress, + upsert_module_progress, +) + +from .helpers import ( + create_course_context, + create_informational_task, + create_lesson, + create_module, + create_text_question_task, + create_user, ) class ProgressServiceTests(TestCase): + def setUp(self): + context = create_course_context() + self.user = context.user + self.course = context.course + self.module = context.module + self.lesson = context.lesson + + def assert_save_retry_after_concurrent_create(self, model_class, action): + original_save = model_class.save + first_call = True + + def save_then_raise_once(instance, *args, **kwargs): + nonlocal first_call + if first_call: + first_call = False + original_save(instance, *args, **kwargs) + raise IntegrityError("concurrent create") + return original_save(instance, *args, **kwargs) + + with mock.patch.object( + model_class, + "save", + autospec=True, + side_effect=save_then_raise_once, + ): + return action() + + def create_completed_answer(self, task, **overrides): + values = { + "user": self.user, + "task": task, + "answer_text": "ok", + "status": UserTaskAnswerStatus.SUBMITTED, + "is_correct": True, + } + values.update(overrides) + return UserTaskAnswer.objects.create(**values) + + def test_percent_from_counts_handles_boundaries(self): + self.assertEqual(percent_from_counts(1, 0), 0) + self.assertEqual(percent_from_counts(0, 5), 0) + self.assertEqual(percent_from_counts(5, 5), 100) + self.assertEqual(percent_from_counts(2, 5), 40) + + def test_progress_payload_handles_missing_and_existing_progress(self): + self.assertEqual( + progress_payload(None), + {"status": ProgressStatus.NOT_STARTED, "percent": 0}, + ) + self.assertEqual( + progress_payload( + UserCourseProgress( + status=ProgressStatus.IN_PROGRESS, + percent=20, + ) + ), + {"status": ProgressStatus.IN_PROGRESS, "percent": 20}, + ) + def test_percent_from_total_percent_uses_average_and_truncates(self): percent = percent_from_total_percent(200, 9) self.assertEqual(percent, 22) + def test_percent_from_total_percent_handles_boundaries(self): + self.assertEqual(percent_from_total_percent(50, 0), 0) + self.assertEqual(percent_from_total_percent(0, 5), 0) + self.assertEqual(percent_from_total_percent(1, 3), 0) + self.assertEqual(percent_from_total_percent(350, 3), 100) + + def test_status_from_percent_handles_boundaries_and_blocked(self): + self.assertEqual(status_from_percent(0), ProgressStatus.NOT_STARTED) + self.assertEqual(status_from_percent(100), ProgressStatus.COMPLETED) + self.assertEqual(status_from_percent(50), ProgressStatus.IN_PROGRESS) + self.assertEqual( + status_from_percent(0, allow_blocked=True, blocked=True), + ProgressStatus.BLOCKED, + ) + + def test_build_progress_snapshot_marks_blocked_when_allowed(self): + snapshot = build_progress_snapshot( + 0, + 5, + allow_blocked=True, + blocked=True, + ) + + self.assertEqual(snapshot.percent, 0) + self.assertEqual(snapshot.status, ProgressStatus.BLOCKED) + def test_build_progress_snapshot_from_percent_marks_in_progress(self): snapshot = build_progress_snapshot_from_percent(25) self.assertEqual(snapshot.percent, 25) - self.assertEqual(snapshot.status, "in_progress") + self.assertEqual(snapshot.status, ProgressStatus.IN_PROGRESS) + + def test_build_progress_snapshot_from_percent_normalizes_percent(self): + self.assertEqual(build_progress_snapshot_from_percent(-10).percent, 0) + self.assertEqual(build_progress_snapshot_from_percent(150).percent, 100) + + def test_upsert_course_progress_creates_and_updates_progress(self): + visited_at = timezone.now() + + created = upsert_course_progress( + self.user, + self.course, + percent=30, + touch_visit=True, + visited_at=visited_at, + ) + updated = upsert_course_progress(self.user, self.course, percent=100) + + self.assertEqual(created.pk, updated.pk) + self.assertEqual(UserCourseProgress.objects.count(), 1) + self.assertEqual(updated.status, ProgressStatus.COMPLETED) + self.assertEqual(updated.percent, 100) + self.assertEqual(updated.last_visit_at, visited_at) + + def test_upsert_module_progress_creates_and_updates_progress(self): + created = upsert_module_progress(self.user, self.module, percent=25) + updated = upsert_module_progress(self.user, self.module, percent=0) + + self.assertEqual(created.pk, updated.pk) + self.assertEqual(UserModuleProgress.objects.count(), 1) + self.assertEqual(updated.status, ProgressStatus.NOT_STARTED) + self.assertEqual(updated.percent, 0) + + def test_upsert_lesson_progress_sets_current_task_and_blocked_status(self): + task = create_text_question_task(self.lesson) + + in_progress = upsert_lesson_progress( + self.user, + self.lesson, + completed_tasks=1, + total_tasks=2, + current_task=task, + ) + blocked = upsert_lesson_progress( + self.user, + self.lesson, + completed_tasks=0, + total_tasks=2, + current_task=task, + blocked=True, + ) + + self.assertEqual(in_progress.pk, blocked.pk) + self.assertEqual(UserLessonProgress.objects.count(), 1) + self.assertEqual(blocked.status, ProgressStatus.BLOCKED) + self.assertEqual(blocked.percent, 0) + self.assertIsNone(blocked.current_task) + + def test_touch_course_visit_creates_and_updates_last_visit(self): + first_visit = timezone.now() + second_visit = first_visit + timezone.timedelta(minutes=5) + + created = touch_course_visit(self.user, self.course, visited_at=first_visit) + updated = touch_course_visit(self.user, self.course, visited_at=second_visit) + + self.assertEqual(created.pk, updated.pk) + self.assertEqual(UserCourseProgress.objects.count(), 1) + self.assertEqual(updated.last_visit_at, second_visit) + + def test_upsert_course_progress_retries_after_concurrent_create(self): + visited_at = timezone.now() + + progress = self.assert_save_retry_after_concurrent_create( + UserCourseProgress, + lambda: upsert_course_progress( + self.user, + self.course, + percent=50, + touch_visit=True, + visited_at=visited_at, + ), + ) + + self.assertEqual(progress.percent, 50) + self.assertEqual(progress.last_visit_at, visited_at) + self.assertEqual(UserCourseProgress.objects.count(), 1) + + def test_upsert_module_progress_retries_after_concurrent_create(self): + progress = self.assert_save_retry_after_concurrent_create( + UserModuleProgress, + lambda: upsert_module_progress(self.user, self.module, percent=50), + ) + + self.assertEqual(progress.percent, 50) + self.assertEqual(UserModuleProgress.objects.count(), 1) + + def test_upsert_lesson_progress_retries_after_concurrent_create(self): + task = create_text_question_task(self.lesson) + + progress = self.assert_save_retry_after_concurrent_create( + UserLessonProgress, + lambda: upsert_lesson_progress( + self.user, + self.lesson, + completed_tasks=1, + total_tasks=2, + current_task=task, + ), + ) + + self.assertEqual(progress.percent, 50) + self.assertEqual(progress.current_task, task) + self.assertEqual(UserLessonProgress.objects.count(), 1) + + def test_touch_course_visit_retries_after_concurrent_create(self): + visited_at = timezone.now() + + progress = self.assert_save_retry_after_concurrent_create( + UserCourseProgress, + lambda: touch_course_visit( + self.user, + self.course, + visited_at=visited_at, + ), + ) + + self.assertEqual(progress.last_visit_at, visited_at) + self.assertEqual(UserCourseProgress.objects.count(), 1) + + def test_recalculate_progress_handles_complex_course_and_review_answer(self): + # Arrange + second_lesson = create_lesson(self.module, title="Second lesson", order=2) + second_module = create_module(self.course, title="Second module", order=2) + create_lesson(second_module, title="Other lesson", order=1) + create_text_question_task(second_lesson, title="Later question", order=1) + reviewer = create_user(prefix="reviewer") + + info_task = create_informational_task(self.lesson, order=1) + first_task = create_text_question_task(self.lesson, title="First", order=2) + review_task = create_text_question_task( + self.lesson, + title="Review", + order=3, + check_type="with_review", + ) + fourth_task = create_text_question_task(self.lesson, title="Fourth", order=4) + fifth_task = create_text_question_task(self.lesson, title="Fifth", order=5) + + self.create_completed_answer( + task=info_task, + answer_text="", + status=UserTaskAnswerStatus.SUBMITTED, + is_correct=True, + ) + self.create_completed_answer(task=first_task) + review_answer = UserTaskAnswer.objects.create( + user=self.user, + task=review_task, + answer_text="needs review", + status=UserTaskAnswerStatus.PENDING_REVIEW, + ) + + # Act + recalculate_user_progresses_for_lesson(self.user, self.lesson) + + # Assert + lesson_progress = self.lesson.user_progresses.get(user=self.user) + module_progress = self.module.user_progresses.get(user=self.user) + course_progress = self.course.user_progresses.get(user=self.user) + self.assertEqual(lesson_progress.percent, 40) + self.assertEqual(lesson_progress.status, ProgressStatus.IN_PROGRESS) + self.assertEqual(lesson_progress.current_task, review_task) + self.assertEqual(module_progress.percent, 20) + self.assertEqual(course_progress.percent, 13) + + # Act + review_answer.status = UserTaskAnswerStatus.ACCEPTED + review_answer.is_correct = True + review_answer.reviewed_by = reviewer + review_answer.reviewed_at = timezone.now() + review_answer.save() + for task in (fourth_task, fifth_task): + self.create_completed_answer(task) + + recalculate_user_progresses_for_lesson(self.user, self.lesson) + + # Assert + lesson_progress.refresh_from_db() + module_progress.refresh_from_db() + course_progress.refresh_from_db() + self.assertEqual(lesson_progress.percent, 100) + self.assertEqual(lesson_progress.status, ProgressStatus.COMPLETED) + self.assertIsNone(lesson_progress.current_task) + self.assertEqual(module_progress.percent, 50) + self.assertEqual(course_progress.percent, 33) diff --git a/docs/modules/courses.md b/docs/modules/courses.md index 50722453..7c169e41 100644 --- a/docs/modules/courses.md +++ b/docs/modules/courses.md @@ -132,13 +132,55 @@ review-полей прогресс пользователя пересчитыв ## Тесты -Тесты покрывают: - -- API flow; -- доступность курсов и уроков; -- отправку ответов; -- прогресс; -- learning flow; -- экспорт результатов; -- ручную проверку через admin; -- ограничения на прикрепление файлов. +Тесты модуля лежат в `courses/tests/` и разделены по уровням. + +### API и пользовательские сценарии + +- `test_api.py` - базовый flow прохождения курса через HTTP API. +- `test_api_extended.py` - расширенные сценарии: частичный прогресс, блокировка + следующих уроков, choice-задания, файлы и `text_and_files`. + +Эти тесты проверяют поведение, видимое фронтенду: status code, response payload, +доступность элементов и изменение прогресса после действий пользователя. + +### Бизнес-логика + +- `test_access.py` - доступность курса, модуля и урока, action state и date + labels. +- `test_progress.py` - расчет процентов, статусов, обновление progress-записей, + посещение курса и сложный regression-сценарий с несколькими модулями, уроками + и ручной проверкой. +- `test_learning_flow.py` - порядок прохождения заданий и доступность текущего + задания. +- `test_answers.py` - отправка ответов, `with_review` flow и пересчет прогресса + после проверки через Django admin. + +### Model validation + +- `test_answers.py` - validation для `UserTaskAnswer`, + `UserTaskAnswerOption`, `UserTaskAnswerFile`. +- `test_content_models.py` - validation для `CourseModule`, `CourseTask` и + `CourseTaskOption`. + +Эти тесты фиксируют доменные ограничения модели: обязательные поля для разных +типов заданий, допустимые типы ответов, правила choice-вариантов, лимиты файлов +и review-поля. + +### Admin и экспорт + +- `test_export.py` - экспорт результатов курса из Django admin. +- `test_answers.py` - ручная проверка ответа через admin и автоматический + пересчет прогресса. + +### Что считается критичным + +Критичные regression-сценарии: + +- пользователь не может открыть недоступный урок; +- пользователь проходит задания строго по порядку; +- `file_ids` не могут ссылаться на файлы другого пользователя; +- `with_review` ответ блокирует продолжение до проверки; +- после ручной проверки пересчитывается прогресс урока, модуля и курса; +- сложный курс с несколькими модулями и уроками считает частичный прогресс + корректно; +- опубликованные choice-задания не могут быть некорректно сконфигурированы.