From 12bc951b962efc557d4e188e754299acc2cc786c Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 8 May 2026 10:39:21 +0500 Subject: [PATCH 01/32] =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B0?= =?UTF-8?q?=20=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 02/32] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=BA=D1=83=D1=80=D1=81=D0=BE=D0=B2:=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=D1=85=20=D0=BF=D1=80=D0=BF?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8F,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=87=D1=91=D1=82=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B0=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20=D0=BE=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Dja?= =?UTF-8?q?ngo=20admin,=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=8F=D0=B2=D0=BD=D0=B0=D1=8F=20=D1=81=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20response=20p?= =?UTF-8?q?ayload=20=D0=B4=D0=BB=D1=8F=20read=20API=20=D0=BA=D1=83=D1=80?= =?UTF-8?q?=D1=81=D0=BE=D0=B2.=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=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 03/32] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D0=BA=D1=83=D1=80=D1=81?= =?UTF-8?q?=D0=BE=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 04/32] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D1=83=D1=8E=D1=89=D0=B0=D1=8F=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=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-задания не могут быть некорректно сконфигурированы. From 32457b3d6a13684f2d6b42fc0f2071b2f10e23c9 Mon Sep 17 00:00:00 2001 From: Toksi Date: Mon, 18 May 2026 11:21:10 +0500 Subject: [PATCH 05/32] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=20Projects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/projects.md | 272 +++++++++++++++++- projects/serializers.py | 12 +- projects/signals.py | 15 +- projects/tests.py | 72 ----- projects/tests/__init__.py | 0 projects/tests/helpers.py | 244 ++++++++++++++++ projects/tests/test_project_access.py | 42 +++ projects/tests/test_project_collaborators.py | 110 +++++++ .../tests/test_project_companies_resources.py | 139 +++++++++ projects/tests/test_project_crud.py | 202 +++++++++++++ projects/tests/test_project_duplicate.py | 104 +++++++ projects/tests/test_project_filters.py | 110 +++++++ projects/tests/test_project_goals.py | 90 ++++++ projects/tests/test_project_news.py | 155 ++++++++++ projects/tests/test_project_programs.py | 71 +++++ projects/tests/test_project_signals.py | 44 +++ projects/tests/test_project_subscriptions.py | 36 +++ 17 files changed, 1634 insertions(+), 84 deletions(-) delete mode 100644 projects/tests.py create mode 100644 projects/tests/__init__.py create mode 100644 projects/tests/helpers.py create mode 100644 projects/tests/test_project_access.py create mode 100644 projects/tests/test_project_collaborators.py create mode 100644 projects/tests/test_project_companies_resources.py create mode 100644 projects/tests/test_project_crud.py create mode 100644 projects/tests/test_project_duplicate.py create mode 100644 projects/tests/test_project_filters.py create mode 100644 projects/tests/test_project_goals.py create mode 100644 projects/tests/test_project_news.py create mode 100644 projects/tests/test_project_programs.py create mode 100644 projects/tests/test_project_signals.py create mode 100644 projects/tests/test_project_subscriptions.py diff --git a/docs/modules/projects.md b/docs/modules/projects.md index ba1203cb..e86e8e58 100644 --- a/docs/modules/projects.md +++ b/docs/modules/projects.md @@ -1,3 +1,273 @@ # Projects -TODO +## Назначение + +Projects отвечают за проектную часть Procollab: создание и редактирование +проектов, публичность, участие команды, связь с партнерскими программами, +целями, ресурсами, компаниями, вакансиями, подписками и пользовательскими +действиями вокруг проекта. + +## Статус модуля + +Модуль рабочий, но находится в состоянии технического долга. Основная логика +исторически сосредоточена в крупных файлах `projects/views.py`, +`projects/serializers.py` и `projects/models.py`. + +Перед активным рефакторингом модуль требует: + +- поддержки regression-тестов при дальнейшем рефакторинге; +- уточнения актуальных и legacy-сценариев; +- разделения бизнес-логики из views/serializers на более явные service/query + слои; +- проверки endpoints участников проекта и обновления проекта. + +## Основные возможности + +- создание и редактирование проекта; +- просмотр публичных проектов; +- скрытие проекта из общего каталога через `is_public`; +- работа с черновиками через `draft`; +- привязка проекта к партнерской программе; +- дублирование проекта в партнерскую программу; +- управление участниками проекта; +- смена лидера проекта; +- подписка и отписка от проекта; +- лайки и просмотры проекта; +- цели проекта; +- ресурсы проекта; +- компании-партнеры проекта; +- вакансии и отклики по проекту; +- отображение проекта в чате, новостях и ленте через связанные модули. + +## Архитектура + +- `projects/models.py` - модели проекта, участников, целей, компаний, + ресурсов, ссылок, достижений и остаточной старой модели `ProjectNews`. +- `projects/views.py` - HTTP endpoints и значительная часть orchestration + logic. +- `projects/serializers.py` - request/response contracts, часть validation и + часть бизнес-логики создания/обновления связей. +- `projects/permissions.py` - правила видимости проекта и доступа к операциям. +- `projects/helpers.py` - вспомогательная бизнес-логика: рекомендации + пользователей, обновление ссылок/достижений, привязка к программе. +- `projects/managers.py` - queryset helpers для списков, деталей и счетчиков. +- `projects/signals.py` - side effects при сохранении проекта. +- `projects/admin.py` - настройка Django admin. +- `projects/tests/helpers.py` - тестовые builders/factories для проектов, + пользователей, программ, компаний и ресурсов. +- `projects/tests/test_*.py` - regression-тесты API и ключевых правил модуля. + +## Основные сущности + +- `Project` - проект. +- `Collaborator` - участник проекта. +- `ProjectLink` - ссылка проекта. +- `Achievement` - достижение проекта. +- `ProjectGoal` - цель проекта. +- `Company` - компания. +- `ProjectCompany` - связь проекта и компании. +- `Resource` - ресурс проекта. +- проектные новости - новости внутри проекта; актуальный API реализован через + `news.News` с привязкой к `Project` через `content_type/object_id`. +- `ProjectNews` - старая модель проектных новостей, оставшаяся после переноса + данных в `news.News`. +- `DefaultProjectCover` - дефолтная обложка проекта. +- `DefaultProjectAvatar` - дефолтный аватар проекта. + +## API + +- `GET /projects/` - список публичных проектов. +- `POST /projects/` - создание проекта. +- `GET /projects//` - детали проекта. +- `PATCH /projects//` - частичное обновление проекта. +- `PUT /projects//` - полное обновление проекта. +- `DELETE /projects//` - удаление проекта. +- `POST /projects//like/` - лайк проекта. +- `GET /projects/count/` - счетчики проектов. +- `GET /projects//recommended_users` - рекомендованные пользователи. +- `GET /projects//collaborators/` - список участников проекта. +- `POST /projects//collaborators/` - добавление участников проекта. +- `DELETE /projects//collaborators/` - удаление участника проекта. +- `DELETE /projects//collaborators/leave/` - выход пользователя из проекта. +- `PATCH /projects//collaborators//switch-leader/` - + смена лидера проекта. +- `GET /projects//news/` - список новостей внутри проекта. +- `POST /projects//news/` - создание новости проекта. +- `GET /projects//news//` - детальная новость проекта. +- `PATCH /projects//news//` - редактирование новости проекта. +- `DELETE /projects//news//` - удаление новости проекта. +- `POST /projects//news//set_viewed/` - просмотр новости проекта. +- `POST /projects//news//set_liked/` - лайк новости проекта. +- `POST /projects//subscribe/` - подписка на проект. +- `POST /projects//unsubscribe/` - отписка от проекта. +- `GET /projects//subscribers/` - подписчики проекта. +- `GET /projects//goals/` - цели проекта. +- `POST /projects//goals/` - массовое создание целей проекта. +- `GET /projects//goals//` - детальная цель проекта. +- `PATCH /projects//goals//` - обновление цели проекта. +- `GET /projects//resources/` - ресурсы проекта. +- `POST /projects//resources/` - создание ресурса проекта. +- `GET /projects//resources//` - детальный ресурс проекта. +- `PATCH /projects//resources//` - обновление ресурса. +- `POST /projects//companies/` - создание или привязка компании к проекту. +- `GET /projects//companies/list/` - список компаний проекта. +- `PATCH /projects//companies//` - обновление связи с компанией. +- `DELETE /projects//companies//` - удаление связи с компанией. +- `POST /projects/assign-to-program/` - дублирование проекта и привязка к + партнерской программе. +- `GET /projects//responses/` - отклики на вакансии проекта. + +## Основные сценарии + +### 1. Создание проекта + +Пользователь создает проект через `POST /projects/`. Лидер проекта +подставляется из текущего пользователя. + +После создания проекта срабатывают side effects: + +- создается `Collaborator` для лидера проекта; +- если проект опубликован, создается `ProjectChat`; +- при изменении `draft` синхронизируется активность вакансий и новости в ленте. + +Если при создании передан `partner_program_id`, проект дополнительно +привязывается к партнерской программе. + +### 2. Просмотр проекта + +Пользователь открывает список проектов или карточку проекта. В публичном +каталоге отображаются проекты с `draft = False` и `is_public = True`. + +Детальный просмотр проекта учитывает `ProjectVisibilityPermission`. +Непубличный проект доступен: + +- лидеру; +- администраторам; +- участникам команды; +- пользователям с invite; +- менеджерам и экспертам связанной партнерской программы. + +### 3. Редактирование проекта + +Редактирование проекта выполняется через `PUT` или `PATCH`. + +При обновлении отдельно обрабатываются: + +- `partner_program_id`; +- `achievements`, если этот сценарий останется актуальным; +- `links`, если этот сценарий останется актуальным. + +Сейчас часть этой логики находится во view/helper слое и требует последующего +выноса в более явный service layer. + +### 4. Участники проекта + +Проект хранит участников через модель `Collaborator`. + +Поддерживаются сценарии: + +- просмотр участников; +- добавление участников; +- удаление участника; +- выход пользователя из проекта; +- смена лидера проекта. + +Если проект привязан к партнерской программе, `Collaborator.clean()` проверяет, +что добавляемый пользователь является участником этой программы. + +### 5. Партнерская программа + +Проект может быть связан с партнерской программой через `PartnerProgramProject` +и `PartnerProgramUserProfile`. + +Привязка учитывает: + +- дедлайн подачи проектов; +- завершенность программы; +- участие пользователя в программе; +- возможность менеджера привязывать проект к программе. + +Есть отдельный сценарий дублирования проекта в программу через +`POST /projects/assign-to-program/`. + +### 6. Цели, ресурсы и компании + +Проект может содержать: + +- цели (`ProjectGoal`); +- компании-партнеры (`Company`, `ProjectCompany`); +- ресурсы (`Resource`). + +`Resource` может быть связан с компанией только если эта компания уже является +партнером проекта. + +### 7. Новости проекта + +Новости внутри проекта доступны через `/projects//news/`. +Несмотря на проектный URL, актуальная реализация использует общий модуль +`news`: запись хранится в `news.News`, а связь с проектом задается через +`content_type = Project` и `object_id = project.id`. + +Старые `ProjectNews`, `ProjectNews*Serializer` и `ProjectNews*View` остаются в +коде, но текущие routes проекта подключены к `news.views`. + +## Ограничения и правила + +- Публичный каталог показывает только `draft = False` и `is_public = True`. +- Непубличные проекты требуют проверки `ProjectVisibilityPermission`. +- Лидер проекта автоматически становится участником проекта. +- Лидер не должен удаляться из проекта как обычный участник. +- Проект, связанный с программой, ограничивает редактирование после завершения + программы. +- Компания может быть связана с проектом только один раз. +- Ресурс может ссылаться только на компанию, уже привязанную к проекту. +- Проектные новости являются живым frontend-сценарием, но текущие routes + используют общий модуль `news`, а не старую модель `ProjectNews`. + +## Тесты + +Для модуля добавлен первый слой regression-тестов и тестовых helpers. + +`projects/tests/helpers.py` содержит builders/factories для повторяемых +сущностей: + +- user; +- project; +- collaborator; +- partner program; +- company; +- project company; +- project goal; +- resource. + +Текущие regression-тесты проверяют: + +- создание проекта и side effects: лидер становится collaborator, создается чат + для опубликованного проекта; +- `PUT /projects//` по frontend-контракту: полное обновление формы проекта, + сохранение текущего лидера, привязка к программе через `partner_program_id` + для участника программы и запрет обновления посторонним пользователем; +- visibility rules для публичных и непубличных проектов; +- привязка проекта к партнерской программе; +- запрет привязки к программе без участия пользователя в программе; +- дублирование проекта в партнерскую программу с копированием данных и + участников; +- запрет дублирования проекта пользователем, который не является лидером; +- upsert компаний проекта; +- создание ресурсов проекта; +- запрет привязки ресурса к компании, которая не является партнером проекта; +- создание списка целей проекта; +- частичное обновление одной цели проекта; +- запрет создания целей пользователем, который не является лидером проекта; +- подписка, отписка и список подписчиков проекта; +- новости проекта: создание лидером, список внутри проекта, detail для + модалки, отметка просмотра, лайк/снятие лайка, редактирование и удаление + лидером, запрет создания не-лидером; +- участники проекта: просмотр команды, удаление существующего участника + лидером, выход участника из проекта, запрет выхода лидера, смена лидера на + существующего участника и отказ при смене лидера на пользователя вне команды; +- фильтры списка проектов: `industry`, `leader`, `partner_program`, + `is_company`, `collaborator__user__in`; +- signals вокруг `draft`: публикация проекта активирует вакансии, создает + feed-news для вакансий и `ProjectChat`; возврат в черновик деактивирует + вакансии и удаляет их feed-news; повторная публикация не создает дубли. diff --git a/projects/serializers.py b/projects/serializers.py index 0d1af8ac..6d9127c0 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.core.cache import cache +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from rest_framework import serializers @@ -216,14 +217,20 @@ def validate(self, attrs): def create(self, validated_data): obj = Resource(**validated_data) - obj.full_clean() + try: + obj.full_clean() + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.message_dict) obj.save() return obj def update(self, instance, validated_data): for key, value in validated_data.items(): setattr(instance, key, value) - instance.full_clean() + try: + instance.full_clean() + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.message_dict) instance.save() return instance @@ -375,6 +382,7 @@ class Meta: "short_description", "image_address", "industry", + "draft", "views_count", "is_company", "partner_program", diff --git a/projects/signals.py b/projects/signals.py index 53dee97f..09f89a5c 100644 --- a/projects/signals.py +++ b/projects/signals.py @@ -25,16 +25,13 @@ def create_project(sender, instance, created, **kwargs): @receiver(post_save, sender=Project) def update_vacancy(sender, instance, created, **kwargs): vacancies = Vacancy.objects.filter(project=instance) - old_values = vacancies.values_list("is_active", flat=True) + old_values_by_id = dict(vacancies.values_list("id", "is_active")) vacancies.update(is_active=False if instance.draft else True) - new_values = vacancies.values_list("is_active", flat=True) - vacancies_list = list(vacancies) - - for i in range(len(new_values)): - old = old_values[i] - new = new_values[i] + for vacancy in vacancies: + old = old_values_by_id[vacancy.id] + new = vacancy.is_active if old != new and new is False: - delete_news_for_model(vacancies_list[i]) + delete_news_for_model(vacancy) elif old != new and new is True: - create_news_for_model(vacancies_list[i]) + create_news_for_model(vacancy) diff --git a/projects/tests.py b/projects/tests.py deleted file mode 100644 index c046d699..00000000 --- a/projects/tests.py +++ /dev/null @@ -1,72 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIRequestFactory, force_authenticate - -from industries.models import Industry -from projects.models import Project -from projects.views import ProjectList, ProjectDetail -from tests.constants import USER_CREATE_DATA -from users.models import CustomUser -from users.views import UserList - - -class ProjectTestCase(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.project_list_view = ProjectList.as_view() - self.user_list_view = UserList.as_view() - self.project_detail_view = ProjectDetail.as_view() - self.project_create_data = { - "name": "Test", - "description": "Test", - "industry": Industry.objects.create(name="Test").id, - } - - def test_project_creation(self): - user = self.user_create() - request = self.factory.post("projects/", self.project_create_data) - force_authenticate(request, user=user) - - response = self.project_list_view(request) - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data["name"], "Test") - self.assertEqual(response.data["leader"], user.pk) - self.assertEqual(response.data["industry"], 1) - # self.assertEqual(response.data["description"], "Test") - - def test_project_creation_with_wrong_data(self): - user = self.user_create() - request = self.factory.post( - "projects/", - { - "name": "T" * 257, - "description": "Test", - "industry": Industry.objects.create(name="Test").id, - }, - ) - force_authenticate(request, user=user) - response = self.project_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_project_update(self): - user = self.user_create() - request = self.factory.post("projects/", self.project_create_data) - force_authenticate(request, user=user) - - response = self.project_list_view(request) - project_id = response.data["id"] - project = Project.objects.get(id=project_id) - - request = self.factory.patch(f"projects/{project.pk}/", {"name": "Test2"}) - force_authenticate(request, user=user) - response = self.project_detail_view(request, pk=project.pk) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["name"], "Test2") - - def user_create(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - user.is_active = True - user.save() - return user diff --git a/projects/tests/__init__.py b/projects/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/projects/tests/helpers.py b/projects/tests/helpers.py new file mode 100644 index 00000000..1e6dee01 --- /dev/null +++ b/projects/tests/helpers.py @@ -0,0 +1,244 @@ +from dataclasses import dataclass +from datetime import timedelta +from uuid import uuid4 + +from django.utils import timezone + +from industries.models import Industry +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from projects.models import ( + Collaborator, + Company, + Project, + ProjectCompany, + ProjectGoal, + Resource, +) +from users.models import CustomUser +from vacancy.models import Vacancy + + +@dataclass(frozen=True) +class ProjectTestContext: + user: CustomUser + project: Project + + +def unique_suffix() -> str: + return uuid4().hex[:8] + + +def unique_digits(length: int = 10) -> str: + return str(uuid4().int)[:length] + + +def create_user(*, prefix: str = "projects-test") -> CustomUser: + suffix = unique_suffix() + return CustomUser.objects.create_user( + email=f"{prefix}-{suffix}@example.com", + password="testpass123", + first_name="Test", + last_name="User", + birthday="2000-01-01", + is_active=True, + ) + + +def create_staff_user(*, prefix: str = "projects-admin") -> CustomUser: + suffix = unique_suffix() + return CustomUser.objects.create_superuser( + email=f"{prefix}-{suffix}@example.com", + password="testpass123", + first_name="Admin", + last_name="User", + ) + + +def create_industry(*, name: str = "Industry") -> Industry: + return Industry.objects.create(name=f"{name} {unique_suffix()}") + + +def create_project( + *, + leader: CustomUser | None = None, + name: str = "Project", + description: str = "Project description", + draft: bool = True, + is_public: bool = True, + industry: Industry | None = None, +) -> Project: + return Project.objects.create( + leader=leader or create_user(prefix="project-leader"), + name=f"{name} {unique_suffix()}", + description=description, + draft=draft, + is_public=is_public, + industry=industry or create_industry(), + ) + + +def create_project_context( + *, + user_prefix: str = "projects-test", + project_name: str = "Project", + draft: bool = True, + is_public: bool = True, +) -> ProjectTestContext: + user = create_user(prefix=user_prefix) + project = create_project( + leader=user, + name=project_name, + draft=draft, + is_public=is_public, + ) + return ProjectTestContext(user=user, project=project) + + +def create_collaborator( + project: Project, + *, + user: CustomUser | None = None, + role: str = "Участник", + specialization: str | None = None, +) -> Collaborator: + collaborator, _ = Collaborator.objects.get_or_create( + project=project, + user=user or create_user(prefix="project-collaborator"), + defaults={ + "role": role, + "specialization": specialization, + }, + ) + return collaborator + + +def create_partner_program( + *, + name: str = "Program", + is_competitive: bool = False, + finished: bool = False, + submission_closed: bool = False, +) -> PartnerProgram: + suffix = unique_suffix() + now = timezone.now() + datetime_finished = now - timedelta(days=1) if finished else now + timedelta(days=30) + registration_ends = ( + now - timedelta(days=1) if submission_closed else now + timedelta(days=10) + ) + return PartnerProgram.objects.create( + name=f"{name} {suffix}", + tag=f"program-{suffix}", + city="Moscow", + is_competitive=is_competitive, + datetime_registration_ends=registration_ends, + datetime_started=now - timedelta(days=1), + datetime_finished=datetime_finished, + draft=False, + ) + + +def add_program_member( + program: PartnerProgram, + user: CustomUser, + *, + project: Project | None = None, +) -> PartnerProgramUserProfile: + return PartnerProgramUserProfile.objects.create( + user=user, + partner_program=program, + project=project, + partner_program_data={}, + ) + + +def link_project_to_program( + project: Project, + program: PartnerProgram, + *, + submitted: bool = False, +) -> PartnerProgramProject: + return PartnerProgramProject.objects.create( + project=project, + partner_program=program, + submitted=submitted, + ) + + +def create_company(*, name: str = "Company", inn: str | None = None) -> Company: + suffix = unique_suffix() + return Company.objects.create( + name=f"{name} {suffix}", + inn=inn or unique_digits(10), + ) + + +def create_project_company( + project: Project, + company: Company | None = None, + *, + contribution: str = "Contribution", + decision_maker: CustomUser | None = None, +) -> ProjectCompany: + return ProjectCompany.objects.create( + project=project, + company=company or create_company(), + contribution=contribution, + decision_maker=decision_maker, + ) + + +def create_resource( + project: Project, + *, + type: str = Resource.ResourceType.INFORMATION, + description: str = "Resource description", + partner_company: Company | None = None, +) -> Resource: + if partner_company: + ProjectCompany.objects.get_or_create( + project=project, + company=partner_company, + ) + + resource = Resource( + project=project, + type=type, + description=description, + partner_company=partner_company, + ) + resource.full_clean() + resource.save() + return resource + + +def create_project_goal( + project: Project, + *, + responsible: CustomUser | None = None, + title: str = "Project goal", + is_done: bool = False, +) -> ProjectGoal: + return ProjectGoal.objects.create( + project=project, + title=f"{title} {unique_suffix()}", + responsible=responsible or project.leader, + is_done=is_done, + ) + + +def create_vacancy( + project: Project, + *, + role: str = "Backend developer", + is_active: bool = True, +) -> Vacancy: + return Vacancy.objects.create( + project=project, + role=f"{role} {unique_suffix()}", + description="Vacancy description", + is_active=is_active, + ) diff --git a/projects/tests/test_project_access.py b/projects/tests/test_project_access.py new file mode 100644 index 00000000..db8c4106 --- /dev/null +++ b/projects/tests/test_project_access.py @@ -0,0 +1,42 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from .helpers import ( + create_collaborator, + create_project, + create_project_context, + create_user, +) + + +class ProjectVisibilityRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_private_project_is_hidden_from_unrelated_user(self): + project = create_project(draft=False, is_public=False) + unrelated_user = create_user(prefix="unrelated-project-user") + self.client.force_authenticate(unrelated_user) + + response = self.client.get(f"/projects/{project.id}/") + + self.assertEqual(response.status_code, 403) + + def test_private_project_is_available_to_leader(self): + context = create_project_context(draft=False, is_public=False) + self.client.force_authenticate(context.user) + + response = self.client.get(f"/projects/{context.project.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], context.project.id) + + def test_private_project_is_available_to_collaborator(self): + project = create_project(draft=False, is_public=False) + collaborator = create_collaborator(project).user + self.client.force_authenticate(collaborator) + + response = self.client.get(f"/projects/{project.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], project.id) diff --git a/projects/tests/test_project_collaborators.py b/projects/tests/test_project_collaborators.py new file mode 100644 index 00000000..de82cefa --- /dev/null +++ b/projects/tests/test_project_collaborators.py @@ -0,0 +1,110 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from projects.models import Collaborator + +from .helpers import ( + create_collaborator, + create_project_context, + create_user, +) + + +class ProjectCollaboratorRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + context = create_project_context( + user_prefix="project-collaborator-leader", + draft=True, + ) + self.leader = context.user + self.project = context.project + self.client.force_authenticate(self.leader) + + def test_get_collaborators_returns_project_team(self): + teammate = create_user(prefix="project-collaborator-teammate") + create_collaborator( + self.project, + user=teammate, + role="Engineer", + specialization="Backend", + ) + + response = self.client.get(f"/projects/{self.project.id}/collaborators/") + + self.assertEqual(response.status_code, 200) + collaborator_ids = [ + collaborator["user_id"] for collaborator in response.data["collaborators"] + ] + self.assertIn(self.leader.id, collaborator_ids) + self.assertIn(teammate.id, collaborator_ids) + + def test_leader_can_delete_existing_collaborator(self): + teammate = create_user(prefix="project-collaborator-delete") + create_collaborator(self.project, user=teammate) + + response = self.client.delete( + f"/projects/{self.project.id}/collaborators/?id={teammate.id}", + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse( + Collaborator.objects.filter(project=self.project, user=teammate).exists() + ) + + def test_collaborator_can_leave_project(self): + teammate = create_user(prefix="project-collaborator-leave") + create_collaborator(self.project, user=teammate) + self.client.force_authenticate(teammate) + + response = self.client.delete( + f"/projects/{self.project.id}/collaborators/leave/" + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse( + Collaborator.objects.filter(project=self.project, user=teammate).exists() + ) + + def test_leader_cannot_leave_project_as_regular_collaborator(self): + response = self.client.delete( + f"/projects/{self.project.id}/collaborators/leave/" + ) + + self.assertEqual(response.status_code, 422) + self.assertTrue( + Collaborator.objects.filter(project=self.project, user=self.leader).exists() + ) + + def test_leader_can_switch_project_leader_to_existing_collaborator(self): + teammate = create_user(prefix="project-collaborator-new-leader") + create_collaborator( + self.project, + user=teammate, + role="Engineer", + ) + + response = self.client.patch( + ( + f"/projects/{self.project.id}/collaborators/" + f"{teammate.id}/switch-leader/" + ) + ) + + self.assertEqual(response.status_code, 204) + self.project.refresh_from_db() + self.assertEqual(self.project.leader, teammate) + + def test_switch_leader_rejects_user_who_is_not_project_collaborator(self): + outsider = create_user(prefix="project-collaborator-outsider") + + response = self.client.patch( + ( + f"/projects/{self.project.id}/collaborators/" + f"{outsider.id}/switch-leader/" + ) + ) + + self.assertEqual(response.status_code, 422) + self.project.refresh_from_db() + self.assertEqual(self.project.leader, self.leader) diff --git a/projects/tests/test_project_companies_resources.py b/projects/tests/test_project_companies_resources.py new file mode 100644 index 00000000..dc461c39 --- /dev/null +++ b/projects/tests/test_project_companies_resources.py @@ -0,0 +1,139 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from projects.models import Company, ProjectCompany, Resource + +from .helpers import ( + create_company, + create_project_context, + create_resource, +) + + +class ProjectCompanyRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + context = create_project_context( + user_prefix="project-company-user", + draft=True, + ) + self.user = context.user + self.project = context.project + self.client.force_authenticate(self.user) + + def test_company_upsert_creates_company_and_project_link(self): + response = self.client.post( + f"/projects/{self.project.id}/companies/", + { + "name": "Industrial partner", + "inn": "7701234567", + "contribution": "Equipment", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + company = Company.objects.get(inn="7701234567") + link = ProjectCompany.objects.get(project=self.project, company=company) + self.assertEqual(link.contribution, "Equipment") + self.assertEqual(response.data["company"]["id"], company.id) + + def test_company_upsert_updates_existing_project_link_without_duplicate(self): + company = create_company(inn="7707654321") + ProjectCompany.objects.create( + project=self.project, + company=company, + contribution="Initial contribution", + ) + + response = self.client.post( + f"/projects/{self.project.id}/companies/", + { + "name": company.name, + "inn": company.inn, + "contribution": "Updated contribution", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual( + ProjectCompany.objects.filter(project=self.project, company=company).count(), + 1, + ) + link = ProjectCompany.objects.get(project=self.project, company=company) + self.assertEqual(link.contribution, "Updated contribution") + + +class ProjectResourceRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + context = create_project_context( + user_prefix="project-resource-user", + draft=True, + ) + self.user = context.user + self.project = context.project + self.client.force_authenticate(self.user) + + def test_create_resource_without_partner_company(self): + response = self.client.post( + f"/projects/{self.project.id}/resources/", + { + "type": Resource.ResourceType.INFORMATION, + "description": "Need media coverage", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + resource = Resource.objects.get(pk=response.data["id"]) + self.assertEqual(resource.project, self.project) + self.assertIsNone(resource.partner_company) + + def test_create_resource_accepts_only_project_partner_company(self): + partner_company = create_company(inn="7701111111") + ProjectCompany.objects.create(project=self.project, company=partner_company) + + response = self.client.post( + f"/projects/{self.project.id}/resources/", + { + "type": Resource.ResourceType.INFRASTRUCTURE, + "description": "Need laboratory", + "partner_company": partner_company.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + resource = Resource.objects.get(pk=response.data["id"]) + self.assertEqual(resource.partner_company, partner_company) + + def test_create_resource_rejects_company_not_linked_to_project(self): + foreign_company = create_company(inn="7702222222") + + response = self.client.post( + f"/projects/{self.project.id}/resources/", + { + "type": Resource.ResourceType.INFRASTRUCTURE, + "description": "Need laboratory", + "partner_company": foreign_company.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("partner_company", response.data) + + def test_resource_helper_creates_valid_partner_link_when_company_is_given(self): + company = create_company(inn="7703333333") + + resource = create_resource(self.project, partner_company=company) + + self.assertEqual(resource.partner_company, company) + self.assertTrue( + ProjectCompany.objects.filter( + project=self.project, + company=company, + ).exists() + ) diff --git a/projects/tests/test_project_crud.py b/projects/tests/test_project_crud.py new file mode 100644 index 00000000..6ccf5a52 --- /dev/null +++ b/projects/tests/test_project_crud.py @@ -0,0 +1,202 @@ +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient + +from chats.models import ProjectChat +from projects.models import Collaborator, Project + +from .helpers import ( + add_program_member, + create_industry, + create_partner_program, + create_project, + create_project_context, + create_user, +) + + +class ProjectCreateAndReadAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = create_user(prefix="project-api-user") + self.client.force_authenticate(self.user) + self.industry = create_industry() + + def test_create_project_sets_current_user_as_leader_and_creates_collaborator(self): + response = self.client.post( + "/projects/", + { + "name": "Research platform", + "description": "Platform for applied research", + "industry": self.industry.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + project = Project.objects.get(pk=response.data["id"]) + self.assertEqual(project.leader, self.user) + self.assertEqual(response.data["leader"], self.user.id) + self.assertTrue( + Collaborator.objects.filter( + project=project, + user=self.user, + role="Основатель", + ).exists() + ) + + def test_create_published_project_creates_project_chat_and_shows_in_list(self): + response = self.client.post( + "/projects/", + { + "name": "Published research platform", + "description": "Visible project", + "industry": self.industry.id, + "draft": False, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + project = Project.objects.get(pk=response.data["id"]) + self.assertFalse(project.draft) + self.assertTrue(project.is_public) + self.assertTrue(ProjectChat.objects.filter(project=project).exists()) + + list_response = self.client.get("/projects/") + + self.assertEqual(list_response.status_code, 200) + listed_ids = [item["id"] for item in list_response.data["results"]] + self.assertIn(project.id, listed_ids) + + def test_draft_project_is_hidden_from_public_list(self): + draft_project = create_project( + leader=self.user, + name="Draft project", + draft=True, + ) + published_project = create_project( + leader=self.user, + name="Published project", + draft=False, + is_public=True, + ) + + response = self.client.get("/projects/") + + self.assertEqual(response.status_code, 200) + listed_ids = [item["id"] for item in response.data["results"]] + self.assertNotIn(draft_project.id, listed_ids) + self.assertIn(published_project.id, listed_ids) + + def test_project_detail_adds_view_for_authenticated_user(self): + project = create_project( + leader=self.user, + name="Detail project", + draft=False, + is_public=True, + ) + + response = self.client.get(f"/projects/{project.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], project.id) + self.assertEqual(response.data["name"], project.name) + self.assertEqual(response.data["views_count"], 1) + + +class ProjectPutUpdateAPITests(TestCase): + def setUp(self): + self.client = APIClient() + context = create_project_context( + user_prefix="project-put-leader", + project_name="Initial project", + draft=True, + ) + self.user = context.user + self.project = context.project + self.industry = create_industry(name="Updated industry") + self.client.force_authenticate(self.user) + + def _full_project_payload(self, **overrides): + payload = { + "name": "Updated project", + "description": "Updated project description", + "region": "Moscow", + "industry": self.industry.id, + "presentation_address": "https://example.com/presentation", + "image_address": "https://example.com/image.png", + "cover_image_address": "https://example.com/cover.png", + "draft": False, + "actuality": "Updated actuality", + "problem": "Updated problem", + "target_audience": "Updated target audience", + "implementation_deadline": "2026-12-31", + "trl": 5, + } + payload.update(overrides) + return payload + + def test_put_updates_full_project_form_without_changing_leader(self): + response = self.client.put( + f"/projects/{self.project.id}/", + self._full_project_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.project.refresh_from_db() + self.assertEqual(self.project.name, "Updated project") + self.assertEqual(self.project.description, "Updated project description") + self.assertEqual(self.project.region, "Moscow") + self.assertEqual(self.project.industry, self.industry) + self.assertEqual( + self.project.presentation_address, + "https://example.com/presentation", + ) + self.assertEqual(self.project.image_address, "https://example.com/image.png") + self.assertEqual( + self.project.cover_image_address, + "https://example.com/cover.png", + ) + self.assertFalse(self.project.draft) + self.assertEqual(self.project.actuality, "Updated actuality") + self.assertEqual(self.project.problem, "Updated problem") + self.assertEqual(self.project.target_audience, "Updated target audience") + self.assertEqual(str(self.project.implementation_deadline), "2026-12-31") + self.assertEqual(self.project.trl, 5) + self.assertEqual(self.project.leader, self.user) + + def test_put_binds_project_to_program_when_user_is_program_member(self): + program = create_partner_program(name="PUT target program") + member_profile = add_program_member(program, self.user) + + response = self.client.put( + f"/projects/{self.project.id}/", + self._full_project_payload(partner_program_id=program.id), + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.project.refresh_from_db() + member_profile.refresh_from_db() + self.assertTrue( + self.project.program_links.filter(partner_program=program).exists() + ) + self.assertEqual(member_profile.project, self.project) + self.assertFalse(self.project.is_public) + + def test_non_leader_cannot_put_update_project(self): + non_leader = create_user(prefix="project-put-non-leader") + self.client.force_authenticate(non_leader) + + response = self.client.put( + f"/projects/{self.project.id}/", + self._full_project_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.project.refresh_from_db() + self.assertNotEqual(self.project.name, "Updated project") diff --git a/projects/tests/test_project_duplicate.py b/projects/tests/test_project_duplicate.py new file mode 100644 index 00000000..ca864cd2 --- /dev/null +++ b/projects/tests/test_project_duplicate.py @@ -0,0 +1,104 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject +from projects.models import Collaborator, Project + +from .helpers import ( + add_program_member, + create_collaborator, + create_partner_program, + create_project, + create_user, +) + + +class ProjectDuplicateToProgramRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="project-duplicate-user") + self.program = create_partner_program(name="Duplicate target program") + add_program_member(self.program, self.user) + self.client.force_authenticate(self.user) + + def test_duplicate_project_copies_data_collaborators_and_binds_to_program(self): + original_project = create_project( + leader=self.user, + name="Original project", + description="Original project description", + draft=False, + is_public=True, + ) + original_project.region = "Moscow" + original_project.actuality = "Important problem" + original_project.problem = "Known bottleneck" + original_project.target_audience = "Researchers" + original_project.trl = 5 + original_project.save() + + teammate = create_user(prefix="project-duplicate-teammate") + create_collaborator( + original_project, + user=teammate, + role="Engineer", + specialization="Backend", + ) + + response = self.client.post( + "/projects/assign-to-program/", + { + "project_id": original_project.id, + "partner_program_id": self.program.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + new_project = Project.objects.get(pk=response.data["new_project_id"]) + self.assertNotEqual(new_project.id, original_project.id) + self.assertEqual(new_project.name, original_project.name) + self.assertEqual(new_project.description, original_project.description) + self.assertEqual(new_project.region, original_project.region) + self.assertEqual(new_project.actuality, original_project.actuality) + self.assertEqual(new_project.problem, original_project.problem) + self.assertEqual(new_project.target_audience, original_project.target_audience) + self.assertEqual(new_project.trl, original_project.trl) + self.assertEqual(new_project.industry, original_project.industry) + self.assertEqual(new_project.leader, self.user) + self.assertTrue(new_project.draft) + self.assertFalse(new_project.is_public) + self.assertTrue( + PartnerProgramProject.objects.filter( + project=new_project, + partner_program=self.program, + ).exists() + ) + self.assertTrue( + Collaborator.objects.filter( + project=new_project, + user=teammate, + role="Engineer", + specialization="Backend", + ).exists() + ) + + def test_non_leader_cannot_duplicate_project_to_program(self): + other_leader = create_user(prefix="project-duplicate-other-leader") + original_project = create_project(leader=other_leader) + + response = self.client.post( + "/projects/assign-to-program/", + { + "project_id": original_project.id, + "partner_program_id": self.program.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse( + PartnerProgramProject.objects.filter( + project__name=original_project.name, + partner_program=self.program, + ).exists() + ) diff --git a/projects/tests/test_project_filters.py b/projects/tests/test_project_filters.py new file mode 100644 index 00000000..de10e8fa --- /dev/null +++ b/projects/tests/test_project_filters.py @@ -0,0 +1,110 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from .helpers import ( + add_program_member, + create_collaborator, + create_industry, + create_partner_program, + create_project, + create_user, + link_project_to_program, +) + + +class ProjectListFilterRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="project-filter-user") + self.client.force_authenticate(self.user) + + def _project_ids(self, response): + self.assertEqual(response.status_code, 200) + return {project["id"] for project in response.data["results"]} + + def test_filter_by_industry(self): + target_industry = create_industry(name="Target industry") + other_industry = create_industry(name="Other industry") + target_project = create_project( + leader=self.user, + industry=target_industry, + draft=False, + is_public=True, + ) + other_project = create_project( + leader=self.user, + industry=other_industry, + draft=False, + is_public=True, + ) + + response = self.client.get("/projects/", {"industry": target_industry.id}) + + project_ids = self._project_ids(response) + self.assertIn(target_project.id, project_ids) + self.assertNotIn(other_project.id, project_ids) + + def test_filter_by_leader(self): + other_leader = create_user(prefix="project-filter-other-leader") + target_project = create_project( + leader=self.user, + draft=False, + is_public=True, + ) + other_project = create_project( + leader=other_leader, + draft=False, + is_public=True, + ) + + response = self.client.get("/projects/", {"leader": self.user.id}) + + project_ids = self._project_ids(response) + self.assertIn(target_project.id, project_ids) + self.assertNotIn(other_project.id, project_ids) + + def test_filter_by_partner_program(self): + target_program = create_partner_program(name="Target program") + other_program = create_partner_program(name="Other program") + add_program_member(target_program, self.user) + add_program_member(other_program, self.user) + target_project = create_project(draft=False, is_public=True) + other_project = create_project(draft=False, is_public=True) + link_project_to_program(target_project, target_program) + link_project_to_program(other_project, other_program) + + response = self.client.get( + "/projects/", + {"partner_program": target_program.id}, + ) + + project_ids = self._project_ids(response) + self.assertIn(target_project.id, project_ids) + self.assertNotIn(other_project.id, project_ids) + + def test_filter_by_is_company(self): + company_project = create_project(draft=False, is_public=True) + company_project.is_company = True + company_project.save(update_fields=["is_company"]) + regular_project = create_project(draft=False, is_public=True) + + response = self.client.get("/projects/", {"is_company": "true"}) + + project_ids = self._project_ids(response) + self.assertIn(company_project.id, project_ids) + self.assertNotIn(regular_project.id, project_ids) + + def test_filter_by_collaborator_user_in(self): + collaborator = create_user(prefix="project-filter-collaborator") + target_project = create_project(draft=False, is_public=True) + other_project = create_project(draft=False, is_public=True) + create_collaborator(target_project, user=collaborator) + + response = self.client.get( + "/projects/", + {"collaborator__user__in": str(collaborator.id)}, + ) + + project_ids = self._project_ids(response) + self.assertIn(target_project.id, project_ids) + self.assertNotIn(other_project.id, project_ids) diff --git a/projects/tests/test_project_goals.py b/projects/tests/test_project_goals.py new file mode 100644 index 00000000..299ae7a4 --- /dev/null +++ b/projects/tests/test_project_goals.py @@ -0,0 +1,90 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from projects.models import ProjectGoal + +from .helpers import ( + create_project_context, + create_project_goal, + create_user, +) + + +class ProjectGoalRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + context = create_project_context( + user_prefix="project-goal-leader", + draft=True, + ) + self.user = context.user + self.project = context.project + self.client.force_authenticate(self.user) + + def test_leader_can_create_goals_list(self): + response = self.client.post( + f"/projects/{self.project.id}/goals/", + [ + { + "title": "Prepare prototype", + "responsible": self.user.id, + }, + { + "title": "Run pilot", + "responsible": self.user.id, + }, + ], + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(ProjectGoal.objects.filter(project=self.project).count(), 2) + self.assertEqual( + list( + ProjectGoal.objects.filter(project=self.project) + .order_by("title") + .values_list("title", flat=True) + ), + [ + "Prepare prototype", + "Run pilot", + ], + ) + + def test_leader_can_partially_update_single_goal(self): + goal = create_project_goal( + self.project, + responsible=self.user, + title="Prepare prototype", + ) + + response = self.client.patch( + f"/projects/{self.project.id}/goals/{goal.id}/", + { + "is_done": True, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + goal.refresh_from_db() + self.assertTrue(goal.is_done) + self.assertEqual(goal.project, self.project) + + def test_non_leader_cannot_create_project_goals(self): + non_leader = create_user(prefix="project-goal-non-leader") + self.client.force_authenticate(non_leader) + + response = self.client.post( + f"/projects/{self.project.id}/goals/", + [ + { + "title": "Prepare prototype", + "responsible": non_leader.id, + }, + ], + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse(ProjectGoal.objects.filter(project=self.project).exists()) diff --git a/projects/tests/test_project_news.py b/projects/tests/test_project_news.py new file mode 100644 index 00000000..ff6ec829 --- /dev/null +++ b/projects/tests/test_project_news.py @@ -0,0 +1,155 @@ +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient + +from core.models import Like, View +from news.models import News +from projects.models import ProjectNews + +from .helpers import create_project, create_project_context, create_user + + +class ProjectNewsRegressionTests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + context = create_project_context( + user_prefix="project-news-leader", + draft=False, + is_public=True, + ) + self.leader = context.user + self.project = context.project + self.client.force_authenticate(self.leader) + + def _content_type(self): + return ContentType.objects.get_for_model(News) + + def test_leader_can_create_project_news(self): + response = self.client.post( + f"/projects/{self.project.id}/news/", + { + "text": "Project news text", + "files": [], + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + news = News.objects.get(pk=response.data["id"]) + self.assertEqual(news.content_object, self.project) + self.assertEqual(news.text, "Project news text") + self.assertFalse(ProjectNews.objects.exists()) + + def test_project_news_list_returns_only_visible_news_for_current_project(self): + visible_news = News.objects.add_news(self.project, text="Visible project news") + News.objects.add_news(self.project, text="") + other_project = create_project(draft=False, is_public=True) + other_news = News.objects.add_news(other_project, text="Other project news") + + response = self.client.get(f"/projects/{self.project.id}/news/") + + self.assertEqual(response.status_code, 200) + news_ids = {item["id"] for item in response.data["results"]} + self.assertIn(visible_news.id, news_ids) + self.assertNotIn(other_news.id, news_ids) + + def test_project_news_detail_is_available_for_modal_view(self): + news = News.objects.add_news(self.project, text="Modal project news") + + response = self.client.get(f"/projects/{self.project.id}/news/{news.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], news.id) + self.assertEqual(response.data["text"], "Modal project news") + + def test_project_news_can_be_marked_viewed_and_liked(self): + news = News.objects.add_news(self.project, text="Interactive project news") + content_type = self._content_type() + + viewed_response = self.client.post( + f"/projects/{self.project.id}/news/{news.id}/set_viewed/", + {"is_viewed": True}, + format="json", + ) + liked_response = self.client.post( + f"/projects/{self.project.id}/news/{news.id}/set_liked/", + {"is_liked": True}, + format="json", + ) + + self.assertEqual(viewed_response.status_code, 200) + self.assertEqual(liked_response.status_code, 200) + self.assertTrue( + View.objects.filter( + content_type=content_type, + object_id=news.id, + user=self.leader, + ).exists() + ) + self.assertTrue( + Like.objects.filter( + content_type=content_type, + object_id=news.id, + user=self.leader, + ).exists() + ) + + unliked_response = self.client.post( + f"/projects/{self.project.id}/news/{news.id}/set_liked/", + {"is_liked": False}, + format="json", + ) + + self.assertEqual(unliked_response.status_code, 200) + self.assertFalse( + Like.objects.filter( + content_type=content_type, + object_id=news.id, + user=self.leader, + ).exists() + ) + + def test_project_leader_can_edit_and_delete_project_news(self): + news = News.objects.add_news(self.project, text="Initial project news") + + patch_response = self.client.patch( + f"/projects/{self.project.id}/news/{news.id}/", + { + "text": "Updated project news", + "files": [], + }, + format="json", + ) + + self.assertEqual(patch_response.status_code, 200) + news.refresh_from_db() + self.assertEqual(news.text, "Updated project news") + + delete_response = self.client.delete( + f"/projects/{self.project.id}/news/{news.id}/" + ) + + self.assertEqual(delete_response.status_code, 204) + self.assertFalse(News.objects.filter(pk=news.id).exists()) + + def test_non_leader_cannot_create_project_news(self): + non_leader = create_user(prefix="project-news-non-leader") + self.client.force_authenticate(non_leader) + + response = self.client.post( + f"/projects/{self.project.id}/news/", + { + "text": "Forbidden project news", + "files": [], + }, + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse( + News.objects.get_news(self.project) + .filter(text="Forbidden project news") + .exists() + ) diff --git a/projects/tests/test_project_programs.py b/projects/tests/test_project_programs.py new file mode 100644 index 00000000..fd3e43ef --- /dev/null +++ b/projects/tests/test_project_programs.py @@ -0,0 +1,71 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile + +from .helpers import ( + add_program_member, + create_industry, + create_partner_program, + create_user, +) + + +class ProjectProgramBindingRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="project-program-user") + self.industry = create_industry() + self.client.force_authenticate(self.user) + + def test_program_member_can_create_project_bound_to_program(self): + program = create_partner_program() + member_profile = add_program_member(program, self.user) + + response = self.client.post( + "/projects/", + { + "name": "Program project", + "description": "Project for partner program", + "industry": self.industry.id, + "partner_program_id": program.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + project_id = response.data["id"] + self.assertTrue( + PartnerProgramProject.objects.filter( + project_id=project_id, + partner_program=program, + ).exists() + ) + member_profile.refresh_from_db() + self.assertEqual(member_profile.project_id, project_id) + self.assertFalse(member_profile.project.is_public) + + def test_user_cannot_create_project_for_program_without_membership(self): + program = create_partner_program() + + response = self.client.post( + "/projects/", + { + "name": "Foreign program project", + "description": "Project for unavailable partner program", + "industry": self.industry.id, + "partner_program_id": program.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse( + PartnerProgramUserProfile.objects.filter( + user=self.user, + partner_program=program, + ).exists() + ) + self.assertFalse( + PartnerProgramProject.objects.filter(partner_program=program).exists() + ) diff --git a/projects/tests/test_project_signals.py b/projects/tests/test_project_signals.py new file mode 100644 index 00000000..a0319d25 --- /dev/null +++ b/projects/tests/test_project_signals.py @@ -0,0 +1,44 @@ +from django.test import TestCase + +from chats.models import ProjectChat +from news.models import News + +from .helpers import create_project, create_vacancy + + +class ProjectDraftSignalRegressionTests(TestCase): + def test_publish_project_activates_vacancies_creates_feed_news_and_chat(self): + project = create_project(draft=True) + vacancy = create_vacancy(project, is_active=False) + + project.draft = False + project.save() + + vacancy.refresh_from_db() + self.assertTrue(vacancy.is_active) + self.assertTrue(News.objects.get_news(vacancy).filter(text="").exists()) + self.assertTrue(ProjectChat.objects.filter(project=project).exists()) + + def test_return_project_to_draft_deactivates_vacancies_and_removes_feed_news(self): + project = create_project(draft=False) + vacancy = create_vacancy(project, is_active=True) + + self.assertTrue(News.objects.get_news(vacancy).filter(text="").exists()) + + project.draft = True + project.save() + + vacancy.refresh_from_db() + self.assertFalse(vacancy.is_active) + self.assertFalse(News.objects.get_news(vacancy).filter(text="").exists()) + + def test_repeated_publish_does_not_duplicate_feed_news_or_chat(self): + project = create_project(draft=True) + vacancy = create_vacancy(project, is_active=False) + + project.draft = False + project.save() + project.save() + + self.assertEqual(News.objects.get_news(vacancy).filter(text="").count(), 1) + self.assertEqual(ProjectChat.objects.filter(project=project).count(), 1) diff --git a/projects/tests/test_project_subscriptions.py b/projects/tests/test_project_subscriptions.py new file mode 100644 index 00000000..63c67bae --- /dev/null +++ b/projects/tests/test_project_subscriptions.py @@ -0,0 +1,36 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from .helpers import create_project, create_user + + +class ProjectSubscriptionRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + self.project = create_project(draft=False, is_public=True) + self.user = create_user(prefix="project-subscriber") + self.client.force_authenticate(self.user) + + def test_user_can_subscribe_to_public_project(self): + response = self.client.post(f"/projects/{self.project.id}/subscribe/") + + self.assertEqual(response.status_code, 200) + self.assertTrue(self.project.subscribers.filter(pk=self.user.id).exists()) + + def test_user_can_unsubscribe_from_project(self): + self.project.subscribers.add(self.user) + + response = self.client.post(f"/projects/{self.project.id}/unsubscribe/") + + self.assertEqual(response.status_code, 200) + self.assertFalse(self.project.subscribers.filter(pk=self.user.id).exists()) + + def test_subscribers_list_returns_project_subscribers(self): + subscriber = create_user(prefix="project-subscriber-list") + self.project.subscribers.add(subscriber) + + response = self.client.get(f"/projects/{self.project.id}/subscribers/") + + self.assertEqual(response.status_code, 200) + subscriber_ids = [item["id"] for item in response.data] + self.assertIn(subscriber.id, subscriber_ids) From 3f94f971a18f5ffdb0ceca7e984251ccdb6e146e Mon Sep 17 00:00:00 2001 From: Toksi Date: Mon, 18 May 2026 11:30:54 +0500 Subject: [PATCH 06/32] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?= =?UTF-8?q?=D1=82=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20frontend=20=D0=BD?= =?UTF-8?q?=D0=B0=20dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/nginx/host/dev/dev.procollab.ru | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/deploy/nginx/host/dev/dev.procollab.ru b/deploy/nginx/host/dev/dev.procollab.ru index b8ce3c50..d1b13028 100644 --- a/deploy/nginx/host/dev/dev.procollab.ru +++ b/deploy/nginx/host/dev/dev.procollab.ru @@ -18,11 +18,33 @@ server { listen 443 ssl; server_name dev.procollab.ru; server_tokens off; + root /home/front/app; + index index.html; ssl_certificate /etc/letsencrypt/live/dev.procollab.ru-0001/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/dev.procollab.ru-0001/privkey.pem; - location / { + location ~ ^/(admin|api-auth|files|industries|news|projects|vacancies|core|invites|auth|chats|events|programs|courses|rate-project|feed|api|anymail|ws)(/|$) { + include /etc/nginx/procollab/includes/proxy_app.inc; + } + + location ~ ^/(swagger(\.json|\.yaml)?|swagger/|redoc/?)$ { + include /etc/nginx/procollab/includes/proxy_app.inc; + } + + location ^~ /static/admin/ { + include /etc/nginx/procollab/includes/proxy_app.inc; + } + + location ^~ /static/drf-yasg/ { + include /etc/nginx/procollab/includes/proxy_app.inc; + } + + location ^~ /static/rest_framework/ { include /etc/nginx/procollab/includes/proxy_app.inc; } + + location / { + try_files $uri $uri/ /index.html; + } } From 143a204e7d921a08d353884c721ccb5a4b8bf42f Mon Sep 17 00:00:00 2001 From: Toksi Date: Mon, 18 May 2026 11:44:58 +0500 Subject: [PATCH 07/32] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=B5=D1=81=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=B4=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D1=85=20=D0=BA=D1=83?= =?UTF-8?q?=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 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/courses/tests/helpers.py b/courses/tests/helpers.py index b5d428b7..0cbf0f5a 100644 --- a/courses/tests/helpers.py +++ b/courses/tests/helpers.py @@ -26,6 +26,9 @@ from users.models import CustomUser +DEFAULT_MODULE_START_DATE = date(2026, 1, 1) + + @dataclass(frozen=True) class CourseTestContext: user: CustomUser @@ -110,7 +113,7 @@ def create_module( return CourseModule.objects.create( course=course, title=title, - start_date=start_date_value or date.today(), + start_date=start_date_value or DEFAULT_MODULE_START_DATE, status=status, order=order, ) From 8495994eda1650827fcaf12e59a733afbc61213d Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 20 May 2026 10:21:21 +0500 Subject: [PATCH 08/32] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20News=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/news.md | 181 ++++++++++++++++++++++- news/tests.py | 5 - news/tests/__init__.py | 0 news/tests/helpers.py | 95 ++++++++++++ news/tests/test_news_feed.py | 43 ++++++ news/tests/test_news_manager.py | 31 ++++ news/tests/test_news_project_api.py | 121 +++++++++++++++ news/tests/test_news_user_program_api.py | 93 ++++++++++++ 8 files changed, 563 insertions(+), 6 deletions(-) delete mode 100644 news/tests.py create mode 100644 news/tests/__init__.py create mode 100644 news/tests/helpers.py create mode 100644 news/tests/test_news_feed.py create mode 100644 news/tests/test_news_manager.py create mode 100644 news/tests/test_news_project_api.py create mode 100644 news/tests/test_news_user_program_api.py diff --git a/docs/modules/news.md b/docs/modules/news.md index 67e83aec..1f41ea21 100644 --- a/docs/modules/news.md +++ b/docs/modules/news.md @@ -1,3 +1,182 @@ # News -TODO +## Назначение + +News отвечает за публикации и новости в разных частях продукта. + +Модуль хранит новости в единой модели `News` и связывает их с объектами через +generic relation: + +- проектами; +- пользователями; +- партнерскими программами; +- объектами ленты, например вакансиями. + +Одна и та же модель используется для двух близких, но разных сценариев: + +- обычная новость с текстом и файлами; +- служебная запись ленты для существующего объекта, где `text = ""`. + +## Статус модуля + +Модуль рабочий, но связан с несколькими доменными областями и требует +аккуратного рефакторинга. Сейчас он обслуживает проектные новости, новости +пользователей, новости программ и часть общей ленты. + +Первый слой regression-тестов добавлен для живых сценариев API и feed. + +## Основные возможности + +- создание новости в контексте проекта; +- создание новости в профиле пользователя; +- создание новости в партнерской программе; +- просмотр списка новостей в конкретном контексте; +- просмотр, редактирование и удаление отдельной новости; +- отметка просмотра новости; +- лайк и снятие лайка с новости; +- участие новостей в общей ленте `/feed/`; +- закрепление новости программы через `pin`. + +## Архитектура + +- `news/models.py` - модель `News` с `content_type/object_id`, файлами, лайками, + просмотрами и флагом `pin`. +- `news/managers.py` - `get_news(obj)` и `add_news(obj, **kwargs)` для работы с + generic relation. +- `news/mixins.py` - выбор queryset по контексту URL: project, user или partner + program. +- `news/views.py` - общий API для list/create/detail/update/delete, set_viewed и + set_liked. +- `news/serializers.py` - request/response serializers для списка, detail и feed + представления. +- `news/permissions.py` - права на создание и изменение новости в зависимости от + связанного объекта. +- `news/admin.py` - админка `News`. +- `news/tests/` - regression-тесты живых сценариев модуля. + +## Основные сущности + +- `News` - публикация или запись ленты. +- `content_object` - объект, к которому относится новость. +- `files` - вложения новости. +- `likes` - generic likes через `core.Like`. +- `views` - generic views через `core.View`. +- `pin` - закрепление новости, сейчас используется для новостей программ. + +## API + +Контекстные endpoints: + +- `GET /projects//news/` - список новостей проекта. +- `POST /projects//news/` - создание новости проекта. +- `GET /projects//news//` - детальная новость проекта. +- `PATCH /projects//news//` - редактирование новости + проекта. +- `DELETE /projects//news//` - удаление новости проекта. +- `POST /projects//news//set_viewed/` - просмотр новости + проекта. +- `POST /projects//news//set_liked/` - лайк новости + проекта. + +- `GET /auth/users//news/` - список новостей пользователя. +- `POST /auth/users//news/` - создание новости пользователя. +- `GET /auth/users//news//` - детальная новость пользователя. +- `PATCH /auth/users//news//` - редактирование новости + пользователя. +- `DELETE /auth/users//news//` - удаление новости + пользователя. +- `POST /auth/users//news//set_viewed/` - просмотр новости + пользователя. +- `POST /auth/users//news//set_liked/` - лайк новости + пользователя. + +- `GET /programs//news/` - список новостей программы. +- `POST /programs//news/` - создание новости программы. +- `GET /programs//news//` - детальная новость программы. +- `PATCH /programs//news//` - редактирование новости + программы. +- `DELETE /programs//news//` - удаление новости программы. +- `POST /programs//news//set_viewed/` - просмотр новости + программы. +- `POST /programs//news//set_liked/` - лайк новости + программы. + +Общие endpoints: + +- `GET /news/` - подключен напрямую, но без контекста возвращает пустой список. +- `GET /news//` - подключен напрямую, но без контекста не является + основным пользовательским сценарием. +- `GET /feed/?type=...` - общая лента, которая читает данные из `News`. + +## Основные сценарии + +### 1. Новость проекта + +Лидер проекта создает новость через `/projects//news/`. + +Новость сохраняется в `news.News`, а связь с проектом задается через +`content_type = Project` и `object_id = project.id`. + +Новости проекта с непустым `text` отображаются как новости внутри проекта. +Служебные feed-записи проекта с `text = ""` из списка проектных новостей +исключаются. + +### 2. Новость пользователя + +Пользователь создает новость в своем профиле через `/auth/users//news/`. +Создавать новость за другого пользователя нельзя. + +### 3. Новость программы + +Менеджер партнерской программы создает новость через `/programs//news/`. +Пользователь без роли менеджера программы не может создавать и изменять такие +новости. + +Новости программ сортируются с учетом `pin`: закрепленные новости идут выше +обычных. + +### 4. Просмотры и лайки + +Просмотры и лайки работают через generic-модели `core.View` и `core.Like`. +Они привязаны к самой новости, а не к объекту, которому эта новость посвящена. + +### 5. Лента + +`/feed/` читает `News` и возвращает элементы в формате, зависящем от типа +связанного объекта. + +Для проектных записей важно различать: + +- `text = ""` - служебная запись ленты о проекте; +- `text != ""` - полноценная новость проекта, которая в ленте возвращается как + `type_model = "news"`. + +Лента исключает новости, связанные с непубличными или черновыми проектами. + +## Ограничения и правила + +- Новость проекта может создавать и изменять только лидер проекта. +- Новость пользователя может создавать и изменять только сам пользователь. +- Новость программы может создавать и изменять только менеджер программы. +- Прямой `/news/` без project/user/program context не является основным + пользовательским API. +- Старые `ProjectNews*` в `projects` не являются текущей реализацией проектных + новостей; живые routes используют `news.News`. + +## Тесты + +Текущие regression-тесты проверяют: + +- `NewsManager.add_news()` привязывает новость к content object и файлам; +- `NewsManager.get_news()` возвращает новости нужного объекта; +- лидер проекта может создавать, редактировать и удалять новости проекта; +- пользователь без роли лидера не может создавать новость проекта; +- список новостей проекта исключает служебные feed-записи с `text = ""`; +- новости проекта можно отметить просмотренными и лайкнуть; +- пользователь может создавать новости только в своем профиле; +- менеджер программы может создавать новости программы; +- пользователь без роли менеджера не может создавать новости программы; +- закрепленные новости программы идут выше обычных; +- `/feed/?type=news` возвращает новости пользователя; +- `/feed/?type=project` возвращает проектные новости как `type_model = "news"`; +- feed исключает новости непубличных проектов. diff --git a/news/tests.py b/news/tests.py deleted file mode 100644 index 05362bdb..00000000 --- a/news/tests.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.test import TestCase - - -class NewsTestCase(TestCase): - pass diff --git a/news/tests/__init__.py b/news/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/news/tests/helpers.py b/news/tests/helpers.py new file mode 100644 index 00000000..d8062b36 --- /dev/null +++ b/news/tests/helpers.py @@ -0,0 +1,95 @@ +from datetime import timedelta +from uuid import uuid4 + +from django.utils import timezone + +from files.models import UserFile +from industries.models import Industry +from news.models import News +from partner_programs.models import PartnerProgram +from projects.models import Project +from users.models import CustomUser + + +def unique_suffix() -> str: + return uuid4().hex[:8] + + +def create_user(*, prefix: str = "news-test") -> CustomUser: + suffix = unique_suffix() + return CustomUser.objects.create_user( + email=f"{prefix}-{suffix}@example.com", + password="testpass123", + first_name="Test", + last_name="User", + birthday="2000-01-01", + is_active=True, + ) + + +def create_industry(*, name: str = "Industry") -> Industry: + return Industry.objects.create(name=f"{name} {unique_suffix()}") + + +def create_project( + *, + leader: CustomUser | None = None, + name: str = "Project", + draft: bool = False, + is_public: bool = True, +) -> Project: + return Project.objects.create( + leader=leader or create_user(prefix="news-project-leader"), + name=f"{name} {unique_suffix()}", + description="Project description", + draft=draft, + is_public=is_public, + industry=create_industry(), + ) + + +def create_partner_program( + *, + manager: CustomUser | None = None, + name: str = "Program", +) -> PartnerProgram: + suffix = unique_suffix() + now = timezone.now() + program = PartnerProgram.objects.create( + name=f"{name} {suffix}", + tag=f"program-{suffix}", + city="Moscow", + datetime_registration_ends=now + timedelta(days=10), + datetime_started=now - timedelta(days=1), + datetime_finished=now + timedelta(days=30), + draft=False, + ) + if manager is not None: + program.managers.add(manager) + return program + + +def create_user_file( + user: CustomUser, + *, + name: str = "attachment", + extension: str = "pdf", + mime_type: str = "application/pdf", +) -> UserFile: + suffix = unique_suffix() + return UserFile.objects.create( + link=f"https://cdn.example.com/news/{suffix}/{name}.{extension}", + user=user, + name=name, + extension=extension, + mime_type=mime_type, + size=1024, + ) + + +def create_news_for(obj, *, text: str = "News text", files=None, pin: bool = False) -> News: + news = News.objects.add_news(obj, text=text, files=files or []) + if pin: + news.pin = True + news.save(update_fields=["pin"]) + return news diff --git a/news/tests/test_news_feed.py b/news/tests/test_news_feed.py new file mode 100644 index 00000000..44c9db24 --- /dev/null +++ b/news/tests/test_news_feed.py @@ -0,0 +1,43 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from .helpers import create_news_for, create_project, create_user + + +class NewsFeedTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="news-feed-user") + self.client.force_authenticate(self.user) + + def test_feed_returns_user_news_when_news_filter_requested(self): + news = create_news_for(self.user, text="User feed news") + + response = self.client.get("/feed/?type=news") + + self.assertEqual(response.status_code, 200) + item = response.data["results"][0] + self.assertEqual(item["type_model"], "news") + self.assertEqual(item["content"]["id"], news.id) + self.assertEqual(item["content"]["text"], "User feed news") + + def test_feed_returns_project_news_as_news_content(self): + project = create_project(name="Feed project") + news = create_news_for(project, text="Project feed news") + + response = self.client.get("/feed/?type=project") + + self.assertEqual(response.status_code, 200) + item = response.data["results"][0] + self.assertEqual(item["type_model"], "news") + self.assertEqual(item["content"]["id"], news.id) + self.assertEqual(item["content"]["text"], "Project feed news") + + def test_feed_excludes_news_for_private_project(self): + private_project = create_project(name="Private project", is_public=False) + create_news_for(private_project, text="Private project news") + + response = self.client.get("/feed/?type=project") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"], []) diff --git a/news/tests/test_news_manager.py b/news/tests/test_news_manager.py new file mode 100644 index 00000000..20b24f9a --- /dev/null +++ b/news/tests/test_news_manager.py @@ -0,0 +1,31 @@ +from django.test import TestCase + +from news.models import News + +from .helpers import create_news_for, create_project, create_user_file + + +class NewsManagerTests(TestCase): + def test_add_news_binds_news_to_content_object_and_files(self): + project = create_project() + file = create_user_file(project.leader) + + news = News.objects.add_news( + project, + text="Project update", + files=[file], + ) + + self.assertEqual(news.content_object, project) + self.assertEqual(news.text, "Project update") + self.assertEqual(list(news.files.all()), [file]) + + def test_get_news_returns_only_news_for_requested_object(self): + project = create_project(name="Target project") + other_project = create_project(name="Other project") + target_news = create_news_for(project, text="Target news") + create_news_for(other_project, text="Other news") + + queryset = News.objects.get_news(project).filter(text="Target news") + + self.assertEqual(list(queryset), [target_news]) diff --git a/news/tests/test_news_project_api.py b/news/tests/test_news_project_api.py new file mode 100644 index 00000000..6154c9a0 --- /dev/null +++ b/news/tests/test_news_project_api.py @@ -0,0 +1,121 @@ +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from rest_framework.test import APIClient + +from core.models import Like, View +from news.models import News + +from .helpers import create_news_for, create_project, create_user + + +class ProjectNewsAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="project-news-leader") + self.project = create_project(leader=self.leader) + + def test_project_leader_can_create_project_news(self): + self.client.force_authenticate(self.leader) + + response = self.client.post( + f"/projects/{self.project.id}/news/", + {"text": "Project news"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + news = News.objects.get(pk=response.data["id"]) + self.assertEqual(news.content_object, self.project) + self.assertEqual(news.text, "Project news") + + def test_non_leader_cannot_create_project_news(self): + self.client.force_authenticate(create_user(prefix="project-news-outsider")) + + response = self.client.post( + f"/projects/{self.project.id}/news/", + {"text": "Forbidden news"}, + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse( + News.objects.get_news(self.project).filter(text="Forbidden news").exists() + ) + + def test_project_news_list_excludes_feed_records_without_text(self): + visible_news = create_news_for(self.project, text="Visible project news") + create_news_for(self.project, text="") + + response = self.client.get(f"/projects/{self.project.id}/news/") + + self.assertEqual(response.status_code, 200) + news_ids = {item["id"] for item in response.data["results"]} + self.assertEqual(news_ids, {visible_news.id}) + + def test_project_news_detail_can_be_updated_and_deleted_by_leader(self): + self.client.force_authenticate(self.leader) + news = create_news_for(self.project, text="Initial text") + + update_response = self.client.patch( + f"/projects/{self.project.id}/news/{news.id}/", + {"text": "Updated text"}, + format="json", + ) + + self.assertEqual(update_response.status_code, 200) + news.refresh_from_db() + self.assertEqual(news.text, "Updated text") + + delete_response = self.client.delete(f"/projects/{self.project.id}/news/{news.id}/") + + self.assertEqual(delete_response.status_code, 204) + self.assertFalse(News.objects.filter(pk=news.id).exists()) + + def test_project_news_can_be_marked_viewed_and_liked(self): + user = create_user(prefix="project-news-reader") + self.client.force_authenticate(user) + news = create_news_for(self.project, text="Interactive news") + news_content_type = ContentType.objects.get_for_model(News) + + viewed_response = self.client.post( + f"/projects/{self.project.id}/news/{news.id}/set_viewed/", + {"is_viewed": True}, + format="json", + ) + liked_response = self.client.post( + f"/projects/{self.project.id}/news/{news.id}/set_liked/", + {"is_liked": True}, + format="json", + ) + + self.assertEqual(viewed_response.status_code, 200) + self.assertTrue( + View.objects.filter( + user=user, + content_type=news_content_type, + object_id=news.id, + ).exists() + ) + self.assertEqual(liked_response.status_code, 200) + self.assertTrue( + Like.objects.filter( + user=user, + content_type=news_content_type, + object_id=news.id, + ).exists() + ) + + unlike_response = self.client.post( + f"/projects/{self.project.id}/news/{news.id}/set_liked/", + {"is_liked": False}, + format="json", + ) + + self.assertEqual(unlike_response.status_code, 200) + self.assertFalse( + Like.objects.filter( + user=user, + content_type=news_content_type, + object_id=news.id, + ).exists() + ) diff --git a/news/tests/test_news_user_program_api.py b/news/tests/test_news_user_program_api.py new file mode 100644 index 00000000..8d9effc2 --- /dev/null +++ b/news/tests/test_news_user_program_api.py @@ -0,0 +1,93 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from news.models import News + +from .helpers import create_news_for, create_partner_program, create_user + + +class UserNewsAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="user-news-owner") + + def test_user_can_create_own_news(self): + self.client.force_authenticate(self.user) + + response = self.client.post( + f"/auth/users/{self.user.id}/news/", + {"text": "User news"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + news = News.objects.get(pk=response.data["id"]) + self.assertEqual(news.content_object, self.user) + self.assertEqual(news.text, "User news") + + def test_user_cannot_create_news_for_another_user(self): + self.client.force_authenticate(create_user(prefix="user-news-outsider")) + + response = self.client.post( + f"/auth/users/{self.user.id}/news/", + {"text": "Forbidden user news"}, + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse(News.objects.get_news(self.user).exists()) + + def test_user_news_list_returns_user_news(self): + news = create_news_for(self.user, text="Visible user news") + + response = self.client.get(f"/auth/users/{self.user.id}/news/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"][0]["id"], news.id) + + +class PartnerProgramNewsAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="program-news-manager") + self.program = create_partner_program(manager=self.manager) + + def test_program_manager_can_create_program_news(self): + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.id}/news/", + {"text": "Program news"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + news = News.objects.get(pk=response.data["id"]) + self.assertEqual(news.content_object, self.program) + self.assertEqual(news.text, "Program news") + + def test_non_manager_cannot_create_program_news(self): + self.client.force_authenticate(create_user(prefix="program-news-outsider")) + + response = self.client.post( + f"/programs/{self.program.id}/news/", + {"text": "Forbidden program news"}, + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse(News.objects.get_news(self.program).exists()) + + def test_program_news_list_orders_pinned_news_first(self): + regular_news = create_news_for(self.program, text="Regular program news") + pinned_news = create_news_for( + self.program, + text="Pinned program news", + pin=True, + ) + + response = self.client.get(f"/programs/{self.program.id}/news/") + + self.assertEqual(response.status_code, 200) + news_ids = [item["id"] for item in response.data["results"]] + self.assertEqual(news_ids, [pinned_news.id, regular_news.id]) From cbc485bb8c0d3e063a6d22498124a5b6e790f04e Mon Sep 17 00:00:00 2001 From: Toksi Date: Thu, 21 May 2026 12:03:48 +0500 Subject: [PATCH 09/32] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D1=8F=20News=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20legacy=20ProjectNews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/feed.md | 89 ++++++++++- docs/modules/news.md | 108 ++++++-------- docs/modules/projects.md | 9 +- feed/serializers.py | 9 +- feed/services.py | 11 +- feed/tests/__init__.py | 0 .../tests/test_feed_api.py | 6 +- feed/tests/test_feed_services.py | 43 ++++++ feed/views.py | 6 +- news/mixins.py | 57 ------- news/querysets.py | 38 +++++ news/serializers.py | 71 +-------- news/services.py | 41 +++++ news/tests/test_news_manager.py | 11 ++ news/tests/test_news_project_api.py | 11 +- news/tests/test_news_user_program_api.py | 10 ++ news/views.py | 104 +++++++------ partner_programs/pagination.py | 2 - projects/admin.py | 17 --- .../migrations/0033_delete_projectnews.py | 17 +++ projects/models.py | 45 ------ projects/pagination.py | 14 -- projects/permissions.py | 22 --- projects/serializers.py | 85 +---------- projects/tests/test_project_news.py | 140 +----------------- projects/views.py | 107 +------------ 26 files changed, 391 insertions(+), 682 deletions(-) create mode 100644 feed/tests/__init__.py rename news/tests/test_news_feed.py => feed/tests/test_feed_api.py (90%) create mode 100644 feed/tests/test_feed_services.py delete mode 100644 news/mixins.py create mode 100644 news/querysets.py create mode 100644 news/services.py create mode 100644 projects/migrations/0033_delete_projectnews.py diff --git a/docs/modules/feed.md b/docs/modules/feed.md index 129b81c7..e5635917 100644 --- a/docs/modules/feed.md +++ b/docs/modules/feed.md @@ -1,3 +1,90 @@ # Feed -TODO +## Назначение + +Feed отвечает за общую ленту `/feed/`. + +Модуль не хранит отдельную доменную модель ленты. Он читает записи из +`news.News` и возвращает их во frontend-формате, зависящем от связанного +объекта. + +## Статус модуля + +Модуль рабочий, но небольшой и связан с `news`, `projects` и `vacancy`. +Основная логика сосредоточена во view, serializer и service helpers. + +## Основные возможности + +- сбор общей ленты по query-параметру `type`; +- отображение пользовательских новостей; +- отображение проектных новостей; +- отображение служебных записей для проектов и вакансий; +- исключение записей непубличных или черновых проектов; +- передача признака лайка текущим пользователем. + +## Архитектура + +- `feed/views.py` - endpoint `/feed/`, фильтрация по типам и финальная сборка + response payload. +- `feed/serializers.py` - `FeedNewsResponseSerializer`, который превращает + `news.News` в элемент ленты. +- `feed/services.py` - helpers для лайков и служебных feed-записей. +- `feed/mapping.py` - соответствие content object типам и serializers. +- `feed/constants.py` - типы моделей, для которых signals создают feed-записи. +- `feed/signals.py` - подключение signal handlers. +- `feed/tests/` - regression-тесты API и service helpers. + +## Основные сценарии + +### 1. Пользователь открывает ленту + +Frontend вызывает `/feed/?type=...`. +View выбирает подходящие `news.News`, сериализует их и возвращает элементы +ленты с полями `type_model` и `content`. + +### 2. В ленту попадает обычная новость + +Если `news.News` содержит текст и относится к пользователю или проекту, лента +возвращает ее как новость. + +Проектная новость с текстом возвращается как `type_model = "news"`, даже если +ее `content_object` - проект. + +### 3. В ленту попадает служебная запись + +Служебные feed-записи создаются через `feed.services.create_news_for_model()`. +Они используют `news.News` с пустым `text` и связью на объект, например проект +или вакансию. + +### 4. Проект становится недоступным для публичной ленты + +Если проект черновой или непубличный, связанные с ним записи не возвращаются в +`/feed/`. + +## API + +- `GET /feed/?type=news` - новости пользователей. +- `GET /feed/?type=project` - проектные новости и проектные feed-записи. +- `GET /feed/?type=project|news` - комбинированная выдача по нескольким типам. + +## Ограничения и правила + +- Feed читает данные из `news.News`, но не отвечает за создание обычных + project/user/program news. +- Служебная feed-запись определяется через пустой `text`. +- Signals проектов могут создавать или удалять feed-записи, но тесты этих + side effects остаются в модуле `projects`. +- `DevScript` в `feed/views.py` остается служебным legacy-инструментом и не + является основным пользовательским API. + +## Тесты + +Текущие regression-тесты проверяют: + +- `/feed/?type=news` возвращает пользовательские новости; +- `/feed/?type=project` возвращает проектные новости в frontend-формате; +- новости непубличных проектов не попадают в feed; +- `get_liked_news()` возвращает лайкнутые текущим пользователем записи; +- `create_news_for_model()` создает одну служебную feed-запись без дублей; +- `delete_news_for_model()` удаляет только служебную feed-запись и не трогает + обычную новость с текстом. diff --git a/docs/modules/news.md b/docs/modules/news.md index 1f41ea21..ec2d9e17 100644 --- a/docs/modules/news.md +++ b/docs/modules/news.md @@ -12,7 +12,7 @@ generic relation: - партнерскими программами; - объектами ленты, например вакансиями. -Одна и та же модель используется для двух близких, но разных сценариев: +Одна и та же модель используется для двух сценариев: - обычная новость с текстом и файлами; - служебная запись ленты для существующего объекта, где `text = ""`. @@ -23,7 +23,7 @@ generic relation: аккуратного рефакторинга. Сейчас он обслуживает проектные новости, новости пользователей, новости программ и часть общей ленты. -Первый слой regression-тестов добавлен для живых сценариев API и feed. +Первый слой regression-тестов добавлен для живых сценариев API. ## Основные возможности @@ -41,14 +41,16 @@ generic relation: - `news/models.py` - модель `News` с `content_type/object_id`, файлами, лайками, просмотрами и флагом `pin`. -- `news/managers.py` - `get_news(obj)` и `add_news(obj, **kwargs)` для работы с - generic relation. -- `news/mixins.py` - выбор queryset по контексту URL: project, user или partner - program. +- `news/managers.py` - низкоуровневые `get_news(obj)` и `add_news(obj, + **kwargs)` для работы с generic relation. +- `news/services.py` - явное создание project/user/program news и helpers для + различения обычной новости и feed-записи. +- `news/querysets.py` - явные queryset helpers по контексту URL: project, user + или partner program. - `news/views.py` - общий API для list/create/detail/update/delete, set_viewed и set_liked. -- `news/serializers.py` - request/response serializers для списка, detail и feed - представления. +- `news/serializers.py` - request/response serializers для создания, списка и + detail. - `news/permissions.py` - права на создание и изменение новости в зависимости от связанного объекта. - `news/admin.py` - админка `News`. @@ -63,45 +65,29 @@ generic relation: - `views` - generic views через `core.View`. - `pin` - закрепление новости, сейчас используется для новостей программ. +Feed-запись определяется через helper `is_feed_record(news)`, а обычная новость +через `is_content_news(news)`. Сейчас оба helper'а используют текущий признак +`text`, но вызывающий код не должен напрямую проверять `text == ""`. + ## API -Контекстные endpoints: - -- `GET /projects//news/` - список новостей проекта. -- `POST /projects//news/` - создание новости проекта. -- `GET /projects//news//` - детальная новость проекта. -- `PATCH /projects//news//` - редактирование новости - проекта. -- `DELETE /projects//news//` - удаление новости проекта. -- `POST /projects//news//set_viewed/` - просмотр новости - проекта. -- `POST /projects//news//set_liked/` - лайк новости - проекта. - -- `GET /auth/users//news/` - список новостей пользователя. -- `POST /auth/users//news/` - создание новости пользователя. -- `GET /auth/users//news//` - детальная новость пользователя. -- `PATCH /auth/users//news//` - редактирование новости - пользователя. -- `DELETE /auth/users//news//` - удаление новости - пользователя. -- `POST /auth/users//news//set_viewed/` - просмотр новости - пользователя. -- `POST /auth/users//news//set_liked/` - лайк новости - пользователя. - -- `GET /programs//news/` - список новостей программы. -- `POST /programs//news/` - создание новости программы. -- `GET /programs//news//` - детальная новость программы. -- `PATCH /programs//news//` - редактирование новости - программы. -- `DELETE /programs//news//` - удаление новости программы. -- `POST /programs//news//set_viewed/` - просмотр новости - программы. -- `POST /programs//news//set_liked/` - лайк новости - программы. - -Общие endpoints: +Контекстные endpoints работают для трех базовых URL: + +- `/projects//news/` - новости проекта; +- `/auth/users//news/` - новости пользователя; +- `/programs//news/` - новости партнерской программы. + +Для каждого контекста доступны: + +- `GET ` - список новостей; +- `POST ` - создание новости; +- `GET /` - детальная новость; +- `PATCH /` - редактирование новости; +- `DELETE /` - удаление новости; +- `POST /set_viewed/` - просмотр новости; +- `POST /set_liked/` - лайк новости. + +Связанные endpoints: - `GET /news/` - подключен напрямую, но без контекста возвращает пустой список. - `GET /news//` - подключен напрямую, но без контекста не является @@ -117,9 +103,8 @@ generic relation: Новость сохраняется в `news.News`, а связь с проектом задается через `content_type = Project` и `object_id = project.id`. -Новости проекта с непустым `text` отображаются как новости внутри проекта. -Служебные feed-записи проекта с `text = ""` из списка проектных новостей -исключаются. +Новости проекта с текстом отображаются внутри проекта. Служебные feed-записи +из списка проектных новостей исключаются. ### 2. Новость пользователя @@ -148,8 +133,8 @@ generic relation: Для проектных записей важно различать: - `text = ""` - служебная запись ленты о проекте; -- `text != ""` - полноценная новость проекта, которая в ленте возвращается как - `type_model = "news"`. +- новость с текстом - полноценная новость проекта, которая в ленте возвращается + как `type_model = "news"`. Лента исключает новости, связанные с непубличными или черновыми проектами. @@ -160,23 +145,16 @@ generic relation: - Новость программы может создавать и изменять только менеджер программы. - Прямой `/news/` без project/user/program context не является основным пользовательским API. -- Старые `ProjectNews*` в `projects` не являются текущей реализацией проектных - новостей; живые routes используют `news.News`. +- Несуществующий project/user/program context возвращает `404`. +- Проектные новости реализованы через `news.News`. ## Тесты Текущие regression-тесты проверяют: -- `NewsManager.add_news()` привязывает новость к content object и файлам; -- `NewsManager.get_news()` возвращает новости нужного объекта; -- лидер проекта может создавать, редактировать и удалять новости проекта; -- пользователь без роли лидера не может создавать новость проекта; -- список новостей проекта исключает служебные feed-записи с `text = ""`; -- новости проекта можно отметить просмотренными и лайкнуть; -- пользователь может создавать новости только в своем профиле; -- менеджер программы может создавать новости программы; -- пользователь без роли менеджера не может создавать новости программы; -- закрепленные новости программы идут выше обычных; -- `/feed/?type=news` возвращает новости пользователя; -- `/feed/?type=project` возвращает проектные новости как `type_model = "news"`; -- feed исключает новости непубличных проектов. +- manager, service и query helpers; +- project/user/program API; +- права на создание и изменение новостей; +- лайки и просмотры; +- исключение служебных feed-записей из списка новостей проекта; +- сортировку закрепленных новостей программы. diff --git a/docs/modules/projects.md b/docs/modules/projects.md index e86e8e58..a14d010b 100644 --- a/docs/modules/projects.md +++ b/docs/modules/projects.md @@ -42,7 +42,7 @@ Projects отвечают за проектную часть Procollab: созд ## Архитектура - `projects/models.py` - модели проекта, участников, целей, компаний, - ресурсов, ссылок, достижений и остаточной старой модели `ProjectNews`. + ресурсов, ссылок и достижений. - `projects/views.py` - HTTP endpoints и значительная часть orchestration logic. - `projects/serializers.py` - request/response contracts, часть validation и @@ -69,8 +69,6 @@ Projects отвечают за проектную часть Procollab: созд - `Resource` - ресурс проекта. - проектные новости - новости внутри проекта; актуальный API реализован через `news.News` с привязкой к `Project` через `content_type/object_id`. -- `ProjectNews` - старая модель проектных новостей, оставшаяся после переноса - данных в `news.News`. - `DefaultProjectCover` - дефолтная обложка проекта. - `DefaultProjectAvatar` - дефолтный аватар проекта. @@ -208,9 +206,6 @@ Projects отвечают за проектную часть Procollab: созд `news`: запись хранится в `news.News`, а связь с проектом задается через `content_type = Project` и `object_id = project.id`. -Старые `ProjectNews`, `ProjectNews*Serializer` и `ProjectNews*View` остаются в -коде, но текущие routes проекта подключены к `news.views`. - ## Ограничения и правила - Публичный каталог показывает только `draft = False` и `is_public = True`. @@ -222,7 +217,7 @@ Projects отвечают за проектную часть Procollab: созд - Компания может быть связана с проектом только один раз. - Ресурс может ссылаться только на компанию, уже привязанную к проекту. - Проектные новости являются живым frontend-сценарием, но текущие routes - используют общий модуль `news`, а не старую модель `ProjectNews`. + используют общий модуль `news`. ## Тесты diff --git a/feed/serializers.py b/feed/serializers.py index a46a9a6e..3ce5fa57 100644 --- a/feed/serializers.py +++ b/feed/serializers.py @@ -5,11 +5,12 @@ from files.serializers import UserFileSerializer from news.mapping import NewsMapping from news.models import News +from news.services import is_content_news from projects.models import Project from users.models import CustomUser -class NewsFeedListSerializer(serializers.ModelSerializer): +class FeedNewsResponseSerializer(serializers.ModelSerializer): name = serializers.SerializerMethodField() image_address = serializers.SerializerMethodField() is_user_liked = serializers.SerializerMethodField() @@ -21,13 +22,13 @@ class NewsFeedListSerializer(serializers.ModelSerializer): def get_type_model(self, obj) -> str: model_type = CONTENT_OBJECT_MAPPING[obj.content_type.model] - if obj.text != "" and model_type == "project": + if is_content_news(obj) and model_type == "project": return "news" return model_type def get_content_object(self, obj) -> dict: type_model = obj.content_type.model - if obj.text != "" and self.get_type_model(obj) == "project": + if is_content_news(obj) and self.get_type_model(obj) == "project": type_model = "news" serializer = CONTENT_OBJECT_SERIALIZER_MAPPING[type_model](obj.content_object) return serializer.data @@ -41,7 +42,7 @@ def get_likes_count(self, obj): def get_name(self, obj): if obj.content_type.model == CustomUser.__name__.lower(): return f"{obj.content_object.first_name} {obj.content_object.last_name}" - elif obj.text != "" and obj.content_type.model == Project.__name__.lower(): + elif is_content_news(obj) and obj.content_type.model == Project.__name__.lower(): return f"{obj.content_object.name}" def get_image_address(self, obj): diff --git a/feed/services.py b/feed/services.py index 33b1effe..80e2110a 100644 --- a/feed/services.py +++ b/feed/services.py @@ -3,6 +3,7 @@ from core.models import Like from feed.constants import SIGNALS_MODELS from news.models import News +from news.services import FEED_RECORD_TEXT from users.models import CustomUser @@ -18,7 +19,9 @@ def get_liked_news(user: CustomUser, queryset: list[News]) -> list[int]: def delete_news_for_model(instance: SIGNALS_MODELS): content_type = ContentType.objects.get_for_model(instance) obj = News.objects.filter( - text="", content_type=content_type, object_id=instance.id + text=FEED_RECORD_TEXT, + content_type=content_type, + object_id=instance.id, ).first() if obj: obj.delete() @@ -26,4 +29,8 @@ def delete_news_for_model(instance: SIGNALS_MODELS): def create_news_for_model(instance: SIGNALS_MODELS): content_type = ContentType.objects.get_for_model(instance) - News.objects.get_or_create(text="", content_type=content_type, object_id=instance.id) + News.objects.get_or_create( + text=FEED_RECORD_TEXT, + content_type=content_type, + object_id=instance.id, + ) diff --git a/feed/tests/__init__.py b/feed/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/news/tests/test_news_feed.py b/feed/tests/test_feed_api.py similarity index 90% rename from news/tests/test_news_feed.py rename to feed/tests/test_feed_api.py index 44c9db24..304a6351 100644 --- a/news/tests/test_news_feed.py +++ b/feed/tests/test_feed_api.py @@ -1,13 +1,13 @@ from django.test import TestCase from rest_framework.test import APIClient -from .helpers import create_news_for, create_project, create_user +from news.tests.helpers import create_news_for, create_project, create_user -class NewsFeedTests(TestCase): +class FeedAPITests(TestCase): def setUp(self): self.client = APIClient() - self.user = create_user(prefix="news-feed-user") + self.user = create_user(prefix="feed-user") self.client.force_authenticate(self.user) def test_feed_returns_user_news_when_news_filter_requested(self): diff --git a/feed/tests/test_feed_services.py b/feed/tests/test_feed_services.py new file mode 100644 index 00000000..be87edc0 --- /dev/null +++ b/feed/tests/test_feed_services.py @@ -0,0 +1,43 @@ +from django.test import TestCase + +from core.services import set_like +from feed.services import create_news_for_model, delete_news_for_model, get_liked_news +from news.models import News +from news.services import FEED_RECORD_TEXT +from news.tests.helpers import create_news_for, create_project, create_user + + +class FeedServiceTests(TestCase): + def test_get_liked_news_returns_ids_liked_by_user(self): + user = create_user(prefix="feed-liked-user") + project = create_project(name="Liked feed project") + liked_news = create_news_for(project, text="Liked feed news") + other_news = create_news_for(project, text="Other feed news") + set_like(liked_news, user, True) + + liked_ids = get_liked_news(user, [liked_news, other_news]) + + self.assertEqual(list(liked_ids), [liked_news.id]) + + def test_create_news_for_model_creates_single_feed_record(self): + project = create_project(name="Feed record project") + + create_news_for_model(project) + create_news_for_model(project) + + self.assertEqual( + News.objects.get_news(project).filter(text=FEED_RECORD_TEXT).count(), + 1, + ) + + def test_delete_news_for_model_deletes_only_feed_record(self): + project = create_project(name="Delete feed record project") + content_news = create_news_for(project, text="Content news") + create_news_for_model(project) + + delete_news_for_model(project) + + self.assertFalse( + News.objects.get_news(project).filter(text=FEED_RECORD_TEXT).exists() + ) + self.assertTrue(News.objects.filter(pk=content_news.id).exists()) diff --git a/feed/views.py b/feed/views.py index 39316b8a..07264b4c 100644 --- a/feed/views.py +++ b/feed/views.py @@ -11,11 +11,11 @@ from projects.models import Project from vacancy.models import Vacancy -from .serializers import NewsFeedListSerializer +from .serializers import FeedNewsResponseSerializer class NewSimpleFeed(APIView): - serializator_class = NewsFeedListSerializer + serializator_class = FeedNewsResponseSerializer pagination_class = FeedPagination def _get_filter_data(self) -> list[str]: @@ -54,7 +54,7 @@ def get_queryset(self) -> QuerySet[News]: def get(self, *args, **kwargs): paginator = self.pagination_class() paginated_data = paginator.paginate_queryset(self.get_queryset(), self.request) - serializer = NewsFeedListSerializer( + serializer = FeedNewsResponseSerializer( paginated_data, context={ "user": self.request.user, diff --git a/news/mixins.py b/news/mixins.py deleted file mode 100644 index 936f0022..00000000 --- a/news/mixins.py +++ /dev/null @@ -1,57 +0,0 @@ -from django.contrib.auth import get_user_model -from django.db.models.query import QuerySet -from news.models import News -from partner_programs.models import PartnerProgram -from projects.models import Project - -User = get_user_model() - - -class NewsQuerysetMixin: - """ - Mixin for getting queryset for news - """ - - def get_queryset_for_project(self) -> QuerySet[News]: - """Returns queryset of news for project""" - project_pk = self.kwargs.get("project_pk") - try: - project = Project.objects.get(pk=project_pk) - except Project.DoesNotExist: - # TODO: raise http 404 here - return News.objects.none() - # временное удаление постов для проектов с текстом - return News.objects.get_news(obj=project).exclude( - text="", content_type__model="project" - ) - - def get_queryset_for_program(self) -> QuerySet[News]: - """Returns queryset of news for partner program""" - partnerprogram_pk = self.kwargs.get("partnerprogram_pk") - try: - program = PartnerProgram.objects.get(pk=partnerprogram_pk) - except PartnerProgram.DoesNotExist: - # TODO: raise http 404 here - return News.objects.none() - return News.objects.get_news(obj=program).order_by("-pin", "-datetime_created") - - def get_queryset_for_user(self) -> QuerySet[News]: - """Returns queryset of news for user""" - user_pk = self.kwargs.get("user_pk") - try: - user = User.objects.get(pk=user_pk) - except User.DoesNotExist: - # TODO: raise http 404 here - return News.objects.none() - return News.objects.get_news(obj=user) - - def get_queryset(self) -> QuerySet[News] | None: - """Chooses what queryset to return - for project, program or user""" - if self.kwargs.get("project_pk") is not None: - return self.get_queryset_for_project() - elif self.kwargs.get("partnerprogram_pk") is not None: - return self.get_queryset_for_program() - elif self.kwargs.get("user_pk") is not None: - return self.get_queryset_for_user() - else: - return News.objects.none() diff --git a/news/querysets.py b/news/querysets.py new file mode 100644 index 00000000..dc40b78e --- /dev/null +++ b/news/querysets.py @@ -0,0 +1,38 @@ +from django.contrib.auth import get_user_model +from django.db.models import QuerySet +from django.shortcuts import get_object_or_404 + +from news.models import News +from news.services import FEED_RECORD_TEXT +from partner_programs.models import PartnerProgram +from projects.models import Project + +User = get_user_model() + + +def get_project_news_queryset(project_id: int) -> QuerySet[News]: + project = get_object_or_404(Project, pk=project_id) + return News.objects.get_news(obj=project).exclude( + text=FEED_RECORD_TEXT, + content_type__model="project", + ) + + +def get_user_news_queryset(user_id: int) -> QuerySet[News]: + user = get_object_or_404(User, pk=user_id) + return News.objects.get_news(obj=user) + + +def get_program_news_queryset(program_id: int) -> QuerySet[News]: + program = get_object_or_404(PartnerProgram, pk=program_id) + return News.objects.get_news(obj=program).order_by("-pin", "-datetime_created") + + +def get_news_queryset_for_context(kwargs: dict) -> QuerySet[News]: + if kwargs.get("project_pk") is not None: + return get_project_news_queryset(kwargs["project_pk"]) + if kwargs.get("partnerprogram_pk") is not None: + return get_program_news_queryset(kwargs["partnerprogram_pk"]) + if kwargs.get("user_pk") is not None: + return get_user_news_queryset(kwargs["user_pk"]) + return News.objects.none() diff --git a/news/serializers.py b/news/serializers.py index efd21d70..b395e901 100644 --- a/news/serializers.py +++ b/news/serializers.py @@ -5,15 +5,12 @@ from files.serializers import UserFileSerializer from news.mapping import NewsMapping from news.models import News -from projects.models import Project -from users.models import CustomUser -from feed.mapping import CONTENT_OBJECT_MAPPING, CONTENT_OBJECT_SERIALIZER_MAPPING User = get_user_model() -class NewsListCreateSerializer(serializers.ModelSerializer[News]): +class NewsCreateSerializer(serializers.ModelSerializer[News]): class Meta: model = News fields = [ @@ -22,7 +19,7 @@ class Meta: ] -class NewsListSerializer(serializers.ModelSerializer[News]): +class NewsListResponseSerializer(serializers.ModelSerializer[News]): views_count = serializers.SerializerMethodField() likes_count = serializers.SerializerMethodField() name = serializers.SerializerMethodField() @@ -65,69 +62,7 @@ class Meta: read_only_fields = ["pin"] -class NewsFeedListSerializer(serializers.ModelSerializer): - name = serializers.SerializerMethodField() - image_address = serializers.SerializerMethodField() - is_user_liked = serializers.SerializerMethodField() - files = UserFileSerializer(many=True) - views_count = serializers.SerializerMethodField() - likes_count = serializers.SerializerMethodField() - content_object = serializers.SerializerMethodField() - type_model = serializers.SerializerMethodField() - - def get_type_model(self, obj) -> str: - model_type = CONTENT_OBJECT_MAPPING[obj.content_type.model] - if obj.text != "" and model_type == "project": - return "news" - return model_type - - def get_content_object(self, obj) -> dict: - type_model = obj.content_type.model - if obj.text != "" and self.get_type_model(obj) == "project": - type_model = "news" - serializer = CONTENT_OBJECT_SERIALIZER_MAPPING[type_model](obj.content_object) - return serializer.data - - def get_views_count(self, obj): - return get_views_count(obj) - - def get_likes_count(self, obj): - return get_likes_count(obj) - - def get_name(self, obj): - if obj.content_type.model == CustomUser.__name__.lower(): - return f"{obj.content_object.first_name} {obj.content_object.last_name}" - elif obj.text != "" and obj.content_type.model == Project.__name__.lower(): - return f"{obj.content_object.name}" - - def get_image_address(self, obj): - return NewsMapping.get_image_address(obj.content_object) - - def get_is_user_liked(self, obj): - user = self.context.get("user") - if user: - return is_fan(obj, user) - return False - - class Meta: - model = News - fields = [ - "id", - "name", - "image_address", - "text", - "datetime_created", - "views_count", - "likes_count", - "files", - "is_user_liked", - "content_object", - "type_model", - ] - read_only_fields = ["views_count", "likes_count", "type_model"] - - -class NewsDetailSerializer(serializers.ModelSerializer): +class NewsDetailResponseSerializer(serializers.ModelSerializer): views_count = serializers.SerializerMethodField() likes_count = serializers.SerializerMethodField() name = serializers.SerializerMethodField() diff --git a/news/services.py b/news/services.py new file mode 100644 index 00000000..7718c3bd --- /dev/null +++ b/news/services.py @@ -0,0 +1,41 @@ +from typing import Any + +from news.models import News +from partner_programs.models import PartnerProgram +from projects.models import Project +from users.models import CustomUser + + +FEED_RECORD_TEXT = "" + + +def is_feed_record(news: News) -> bool: + return news.text == FEED_RECORD_TEXT + + +def is_content_news(news: News) -> bool: + return not is_feed_record(news) + + +def create_project_news( + project: Project, + author, + data: dict[str, Any], +) -> News: + return News.objects.add_news(project, **data) + + +def create_user_news( + user: CustomUser, + author, + data: dict[str, Any], +) -> News: + return News.objects.add_news(user, **data) + + +def create_program_news( + program: PartnerProgram, + author, + data: dict[str, Any], +) -> News: + return News.objects.add_news(program, **data) diff --git a/news/tests/test_news_manager.py b/news/tests/test_news_manager.py index 20b24f9a..d5126682 100644 --- a/news/tests/test_news_manager.py +++ b/news/tests/test_news_manager.py @@ -1,6 +1,7 @@ from django.test import TestCase from news.models import News +from news.services import is_content_news, is_feed_record from .helpers import create_news_for, create_project, create_user_file @@ -29,3 +30,13 @@ def test_get_news_returns_only_news_for_requested_object(self): queryset = News.objects.get_news(project).filter(text="Target news") self.assertEqual(list(queryset), [target_news]) + + def test_helpers_distinguish_feed_records_from_content_news(self): + project = create_project() + feed_record = create_news_for(project, text="") + content_news = create_news_for(project, text="Project news") + + self.assertTrue(is_feed_record(feed_record)) + self.assertFalse(is_content_news(feed_record)) + self.assertFalse(is_feed_record(content_news)) + self.assertTrue(is_content_news(content_news)) diff --git a/news/tests/test_news_project_api.py b/news/tests/test_news_project_api.py index 6154c9a0..2ec63f25 100644 --- a/news/tests/test_news_project_api.py +++ b/news/tests/test_news_project_api.py @@ -8,7 +8,7 @@ from .helpers import create_news_for, create_project, create_user -class ProjectNewsAPITests(TestCase): +class ProjectScopedNewsAPITests(TestCase): def setUp(self): self.client = APIClient() self.leader = create_user(prefix="project-news-leader") @@ -52,6 +52,11 @@ def test_project_news_list_excludes_feed_records_without_text(self): news_ids = {item["id"] for item in response.data["results"]} self.assertEqual(news_ids, {visible_news.id}) + def test_missing_project_context_returns_not_found(self): + response = self.client.get("/projects/999999/news/") + + self.assertEqual(response.status_code, 404) + def test_project_news_detail_can_be_updated_and_deleted_by_leader(self): self.client.force_authenticate(self.leader) news = create_news_for(self.project, text="Initial text") @@ -66,7 +71,9 @@ def test_project_news_detail_can_be_updated_and_deleted_by_leader(self): news.refresh_from_db() self.assertEqual(news.text, "Updated text") - delete_response = self.client.delete(f"/projects/{self.project.id}/news/{news.id}/") + delete_response = self.client.delete( + f"/projects/{self.project.id}/news/{news.id}/" + ) self.assertEqual(delete_response.status_code, 204) self.assertFalse(News.objects.filter(pk=news.id).exists()) diff --git a/news/tests/test_news_user_program_api.py b/news/tests/test_news_user_program_api.py index 8d9effc2..2347f9c0 100644 --- a/news/tests/test_news_user_program_api.py +++ b/news/tests/test_news_user_program_api.py @@ -45,6 +45,11 @@ def test_user_news_list_returns_user_news(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["results"][0]["id"], news.id) + def test_missing_user_context_returns_not_found(self): + response = self.client.get("/auth/users/999999/news/") + + self.assertEqual(response.status_code, 404) + class PartnerProgramNewsAPITests(TestCase): def setUp(self): @@ -91,3 +96,8 @@ def test_program_news_list_orders_pinned_news_first(self): self.assertEqual(response.status_code, 200) news_ids = [item["id"] for item in response.data["results"]] self.assertEqual(news_ids, [pinned_news.id, regular_news.id]) + + def test_missing_program_context_returns_not_found(self): + response = self.client.get("/programs/999999/news/") + + self.assertEqual(response.status_code, 404) diff --git a/news/views.py b/news/views.py index f4260417..ae1e6ddb 100644 --- a/news/views.py +++ b/news/views.py @@ -7,14 +7,18 @@ from core.serializers import SetLikedSerializer, SetViewedSerializer from core.services import add_view, set_like -from news.mixins import NewsQuerysetMixin -from news.models import News from news.pagination import NewsPagination from news.permissions import IsNewsCreatorOrReadOnly +from news.querysets import get_news_queryset_for_context from news.serializers import ( - NewsDetailSerializer, - NewsListCreateSerializer, - NewsListSerializer, + NewsCreateSerializer, + NewsDetailResponseSerializer, + NewsListResponseSerializer, +) +from news.services import ( + create_program_news, + create_project_news, + create_user_news, ) from partner_programs.models import PartnerProgram from projects.models import Project @@ -23,90 +27,92 @@ User = get_user_model() -class NewsList(NewsQuerysetMixin, generics.ListCreateAPIView): - serializer_class = NewsListSerializer +class ContextNewsAPIView: + def get_queryset(self): + return get_news_queryset_for_context(self.kwargs) + + def get_news_object(self): + return get_object_or_404(self.get_queryset(), pk=self.kwargs["pk"]) + + +class NewsList(ContextNewsAPIView, generics.ListCreateAPIView): + serializer_class = NewsListResponseSerializer permission_classes = [ProjectVisibilityPermission, IsNewsCreatorOrReadOnly] pagination_class = NewsPagination def post(self, request: Request, *args, **kwargs) -> Response: - serializer = NewsListCreateSerializer(data=request.data) + serializer = NewsCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data if kwargs.get("project_pk"): project = get_object_or_404(Project, pk=kwargs["project_pk"]) - news = News.objects.add_news(project, **data) + news = create_project_news(project, request.user, data) return Response( - NewsDetailSerializer(news).data, status=status.HTTP_201_CREATED + NewsDetailResponseSerializer(news).data, + status=status.HTTP_201_CREATED, ) if kwargs.get("user_pk"): user = get_object_or_404(User, pk=kwargs["user_pk"]) - news = News.objects.add_news(user, **data) + news = create_user_news(user, request.user, data) return Response( - NewsDetailSerializer(news).data, status=status.HTTP_201_CREATED + NewsDetailResponseSerializer(news).data, + status=status.HTTP_201_CREATED, ) if kwargs.get("partnerprogram_pk"): program = get_object_or_404(PartnerProgram, pk=kwargs["partnerprogram_pk"]) - news = News.objects.add_news(program, **data) + news = create_program_news(program, request.user, data) return Response( - NewsDetailSerializer(news).data, status=status.HTTP_201_CREATED + NewsDetailResponseSerializer(news).data, + status=status.HTTP_201_CREATED, ) return Response(status=status.HTTP_400_BAD_REQUEST) def get(self, request: Request, *args, **kwargs) -> Response: news = self.paginate_queryset(self.get_queryset()) context = {"user": request.user} - serializer = NewsListSerializer(news, context=context, many=True) + serializer = NewsListResponseSerializer(news, context=context, many=True) return self.get_paginated_response(serializer.data) -class NewsDetail(NewsQuerysetMixin, generics.RetrieveUpdateDestroyAPIView): - serializer_class = NewsDetailSerializer +class NewsDetail(ContextNewsAPIView, generics.RetrieveUpdateDestroyAPIView): + serializer_class = NewsDetailResponseSerializer permission_classes = [ProjectVisibilityPermission, IsNewsCreatorOrReadOnly] def get(self, request: Request, *args, **kwargs) -> Response: - try: - news = self.get_queryset().get(pk=self.kwargs["pk"]) - context = {"user": request.user} - return Response(NewsDetailSerializer(news, context=context).data) - except News.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) + news = self.get_news_object() + context = {"user": request.user} + return Response(NewsDetailResponseSerializer(news, context=context).data) def update(self, request: Request, *args, **kwargs) -> Response: - try: - news = self.get_queryset().get(pk=self.kwargs["pk"]) - context = {"user": request.user} - serializer = NewsDetailSerializer(news, data=request.data, context=context) - # FIXME: are we sure we need raise_exception=True here? - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data) - except News.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) - - -class NewsDetailSetViewed(NewsQuerysetMixin, generics.CreateAPIView): + news = self.get_news_object() + context = {"user": request.user} + serializer = NewsDetailResponseSerializer( + news, + data=request.data, + context=context, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + + +class NewsDetailSetViewed(ContextNewsAPIView, generics.CreateAPIView): serializer_class = SetViewedSerializer permission_classes = [IsAuthenticated, ProjectVisibilityPermission] def post(self, request: Request, *args, **kwargs) -> Response: - try: - news = self.get_queryset().get(pk=self.kwargs["pk"]) - add_view(news, request.user) - return Response(status=status.HTTP_200_OK) - except News.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) + news = self.get_news_object() + add_view(news, request.user) + return Response(status=status.HTTP_200_OK) -class NewsDetailSetLiked(NewsQuerysetMixin, generics.CreateAPIView): +class NewsDetailSetLiked(ContextNewsAPIView, generics.CreateAPIView): serializer_class = SetLikedSerializer permission_classes = [IsAuthenticated, ProjectVisibilityPermission] def post(self, request: Request, *args, **kwargs) -> Response: - try: - news = self.get_queryset().get(pk=self.kwargs["pk"]) - set_like(news, request.user, request.data.get("is_liked")) - return Response(status=status.HTTP_200_OK) - except News.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) + news = self.get_news_object() + set_like(news, request.user, request.data.get("is_liked")) + return Response(status=status.HTTP_200_OK) diff --git a/partner_programs/pagination.py b/partner_programs/pagination.py index 1518a213..77252350 100644 --- a/partner_programs/pagination.py +++ b/partner_programs/pagination.py @@ -10,8 +10,6 @@ class PartnerProgramPagination(pagination.LimitOffsetPagination): gets the next 10 news after the first 10 news. """ - # fixme: very similar to ProjectNewsPagination from projects\pagination.py - default_limit = 10 limit_query_param = "limit" offset_query_param = "offset" diff --git a/projects/admin.py b/projects/admin.py index 060be9ee..d9739705 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -10,7 +10,6 @@ ProjectCompany, ProjectGoal, ProjectLink, - ProjectNews, Resource, ) @@ -160,22 +159,6 @@ class ProjectGoalAdmin(admin.ModelAdmin): autocomplete_fields = ("project", "responsible") -@admin.register(ProjectNews) -class ProjectNewsAdmin(admin.ModelAdmin): - list_display = ( - "id", - "project", - "datetime_created", - ) - list_display_links = ( - "id", - "project", - "datetime_created", - ) - # todo: set up admin panel for files - filter_horizontal = ("files",) - - @admin.register(Achievement) class AchievementAdmin(admin.ModelAdmin): list_display = ("id", "title", "status", "project") diff --git a/projects/migrations/0033_delete_projectnews.py b/projects/migrations/0033_delete_projectnews.py new file mode 100644 index 00000000..759c7cef --- /dev/null +++ b/projects/migrations/0033_delete_projectnews.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.11 on 2026-05-21 06:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0032_hide_program_projects'), + ('news', '0007_project_news_2_news_news'), + ] + + operations = [ + migrations.DeleteModel( + name='ProjectNews', + ), + ] diff --git a/projects/models.py b/projects/models.py index a4443f78..a8d9833f 100644 --- a/projects/models.py +++ b/projects/models.py @@ -2,7 +2,6 @@ from django.apps import apps from django.contrib.auth import get_user_model -from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import ( MaxLengthValidator, @@ -12,7 +11,6 @@ from django.db import models from django.db.models import UniqueConstraint -from core.models import Like, View from files.models import UserFile from industries.models import Industry from projects.managers import AchievementManager, CollaboratorManager, ProjectManager @@ -355,49 +353,6 @@ def save(self, *args, **kwargs): return super().save(*args, **kwargs) -class ProjectNews(models.Model): - """ - Project news model - """ - - project = models.ForeignKey( - Project, - on_delete=models.CASCADE, - related_name="news", - ) - text = models.TextField( - null=False, - blank=False, - ) - # todo: remove files unused files - files = models.ManyToManyField(UserFile, related_name="projects_news", blank=True) - - views = GenericRelation( - View, - related_query_name="project_views", - ) - likes = GenericRelation( - Like, - related_query_name="project_news", - ) - - datetime_created = models.DateTimeField( - verbose_name="Дата создания", - null=False, - auto_now_add=True, - ) - datetime_updated = models.DateTimeField( - verbose_name="Дата изменения", - null=False, - auto_now=True, - ) - - class Meta: - verbose_name = "Новость проекта" - verbose_name_plural = "Новости проекта" - ordering = ["-datetime_created"] - - class ProjectGoal(models.Model): """ Цель проекта (минимальная версия). diff --git a/projects/pagination.py b/projects/pagination.py index 48d8fad1..ab4caeeb 100644 --- a/projects/pagination.py +++ b/projects/pagination.py @@ -1,20 +1,6 @@ from rest_framework import pagination -class ProjectNewsPagination(pagination.LimitOffsetPagination): - """ - Pagination for project news - - For example: - /projects/1/news/?limit=10&offset=10 - gets the next 10 news after the first 10 news. - """ - - default_limit = 5 - limit_query_param = "limit" - offset_query_param = "offset" - - class ProjectsPagination(pagination.LimitOffsetPagination): """ Pagination for Users diff --git a/projects/permissions.py b/projects/permissions.py index de7da2d7..a4b7d790 100644 --- a/projects/permissions.py +++ b/projects/permissions.py @@ -206,28 +206,6 @@ def _prepare_exception_detail( } -class IsNewsAuthorIsProjectLeaderOrReadOnly(BasePermission): - """ - Allows access to update project news only to leader. - """ - - def has_permission(self, request, view) -> bool: - try: - project = Project.objects.get(pk=view.kwargs["project_pk"]) - if request.method in SAFE_METHODS or (request.user == project.leader): - return True - except Project.DoesNotExist: - pass - return False - - def has_object_permission(self, request, view, obj): - if ( - request.method in SAFE_METHODS and not obj.project.draft - ) or obj.project.leader == request.user: - return True - return False - - class IsProjectLeaderOrReadOnly(BasePermission): """ Читать могут все (в т.ч. анонимы). diff --git a/projects/serializers.py b/projects/serializers.py index 6d9127c0..43c1f7b4 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -5,7 +5,7 @@ from rest_framework import serializers from core.serializers import SkillToObjectSerializer -from core.services import get_likes_count, get_views_count, is_fan +from core.services import get_views_count from core.utils import get_user_online_cache_key from files.serializers import UserFileSerializer from industries.models import Industry @@ -25,7 +25,6 @@ Project, ProjectCompany, ProjectGoal, - ProjectNews, Resource, ) from projects.validators import validate_project @@ -429,88 +428,6 @@ class Meta: ref_name = "Projects" -class ProjectNewsListSerializer(serializers.ModelSerializer): - views_count = serializers.SerializerMethodField() - likes_count = serializers.SerializerMethodField() - project_name = serializers.SerializerMethodField() - project_image_address = serializers.SerializerMethodField() - is_user_liked = serializers.SerializerMethodField() - - def get_project_name(self, obj): - return obj.project.name - - def get_project_image_address(self, obj): - return obj.project.image_address - - def get_views_count(self, obj): - return get_views_count(obj) - - def get_likes_count(self, obj): - return get_likes_count(obj) - - def get_is_user_liked(self, obj): - # fixme: move this method to helpers somewhere - user = self.context.get("user") - if user: - return is_fan(obj, user) - return False - - class Meta: - model = ProjectNews - fields = [ - "id", - "project_name", - "project_image_address", - "text", - "datetime_created", - "views_count", - "likes_count", - "is_user_liked", - "files", - ] - - -class ProjectNewsDetailSerializer(serializers.ModelSerializer): - views_count = serializers.SerializerMethodField() - likes_count = serializers.SerializerMethodField() - project_name = serializers.SerializerMethodField() - project_image_address = serializers.SerializerMethodField() - is_user_liked = serializers.SerializerMethodField() - - def get_project_name(self, obj): - return obj.project.name - - def get_project_image_address(self, obj): - return obj.project.image_address - - def get_views_count(self, obj): - return get_views_count(obj) - - def get_likes_count(self, obj): - return get_likes_count(obj) - - def get_is_user_liked(self, obj): - user = self.context.get("user") - if user: - return is_fan(obj, user) - return False - - class Meta: - model = ProjectNews - fields = [ - "id", - "project_name", - "project_image_address", - "text", - "datetime_created", - "datetime_updated", - "views_count", - "likes_count", - "is_user_liked", - "files", - ] - - class ProjectSubscribersListSerializer(serializers.ModelSerializer): is_online = serializers.SerializerMethodField() diff --git a/projects/tests/test_project_news.py b/projects/tests/test_project_news.py index ff6ec829..6ba46084 100644 --- a/projects/tests/test_project_news.py +++ b/projects/tests/test_project_news.py @@ -1,34 +1,23 @@ -from django.contrib.contenttypes.models import ContentType -from django.core.cache import cache from django.test import TestCase from rest_framework.test import APIClient -from core.models import Like, View from news.models import News -from projects.models import ProjectNews -from .helpers import create_project, create_project_context, create_user +from .helpers import create_project_context -class ProjectNewsRegressionTests(TestCase): - def setUp(self): - cache.clear() - self.client = APIClient() +class ProjectScopedNewsRouteIntegrationTests(TestCase): + def test_project_news_url_creates_news_record_for_project(self): + client = APIClient() context = create_project_context( user_prefix="project-news-leader", draft=False, is_public=True, ) - self.leader = context.user - self.project = context.project - self.client.force_authenticate(self.leader) + client.force_authenticate(context.user) - def _content_type(self): - return ContentType.objects.get_for_model(News) - - def test_leader_can_create_project_news(self): - response = self.client.post( - f"/projects/{self.project.id}/news/", + response = client.post( + f"/projects/{context.project.id}/news/", { "text": "Project news text", "files": [], @@ -38,118 +27,5 @@ def test_leader_can_create_project_news(self): self.assertEqual(response.status_code, 201) news = News.objects.get(pk=response.data["id"]) - self.assertEqual(news.content_object, self.project) + self.assertEqual(news.content_object, context.project) self.assertEqual(news.text, "Project news text") - self.assertFalse(ProjectNews.objects.exists()) - - def test_project_news_list_returns_only_visible_news_for_current_project(self): - visible_news = News.objects.add_news(self.project, text="Visible project news") - News.objects.add_news(self.project, text="") - other_project = create_project(draft=False, is_public=True) - other_news = News.objects.add_news(other_project, text="Other project news") - - response = self.client.get(f"/projects/{self.project.id}/news/") - - self.assertEqual(response.status_code, 200) - news_ids = {item["id"] for item in response.data["results"]} - self.assertIn(visible_news.id, news_ids) - self.assertNotIn(other_news.id, news_ids) - - def test_project_news_detail_is_available_for_modal_view(self): - news = News.objects.add_news(self.project, text="Modal project news") - - response = self.client.get(f"/projects/{self.project.id}/news/{news.id}/") - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["id"], news.id) - self.assertEqual(response.data["text"], "Modal project news") - - def test_project_news_can_be_marked_viewed_and_liked(self): - news = News.objects.add_news(self.project, text="Interactive project news") - content_type = self._content_type() - - viewed_response = self.client.post( - f"/projects/{self.project.id}/news/{news.id}/set_viewed/", - {"is_viewed": True}, - format="json", - ) - liked_response = self.client.post( - f"/projects/{self.project.id}/news/{news.id}/set_liked/", - {"is_liked": True}, - format="json", - ) - - self.assertEqual(viewed_response.status_code, 200) - self.assertEqual(liked_response.status_code, 200) - self.assertTrue( - View.objects.filter( - content_type=content_type, - object_id=news.id, - user=self.leader, - ).exists() - ) - self.assertTrue( - Like.objects.filter( - content_type=content_type, - object_id=news.id, - user=self.leader, - ).exists() - ) - - unliked_response = self.client.post( - f"/projects/{self.project.id}/news/{news.id}/set_liked/", - {"is_liked": False}, - format="json", - ) - - self.assertEqual(unliked_response.status_code, 200) - self.assertFalse( - Like.objects.filter( - content_type=content_type, - object_id=news.id, - user=self.leader, - ).exists() - ) - - def test_project_leader_can_edit_and_delete_project_news(self): - news = News.objects.add_news(self.project, text="Initial project news") - - patch_response = self.client.patch( - f"/projects/{self.project.id}/news/{news.id}/", - { - "text": "Updated project news", - "files": [], - }, - format="json", - ) - - self.assertEqual(patch_response.status_code, 200) - news.refresh_from_db() - self.assertEqual(news.text, "Updated project news") - - delete_response = self.client.delete( - f"/projects/{self.project.id}/news/{news.id}/" - ) - - self.assertEqual(delete_response.status_code, 204) - self.assertFalse(News.objects.filter(pk=news.id).exists()) - - def test_non_leader_cannot_create_project_news(self): - non_leader = create_user(prefix="project-news-non-leader") - self.client.force_authenticate(non_leader) - - response = self.client.post( - f"/projects/{self.project.id}/news/", - { - "text": "Forbidden project news", - "files": [], - }, - format="json", - ) - - self.assertEqual(response.status_code, 403) - self.assertFalse( - News.objects.get_news(self.project) - .filter(text="Forbidden project news") - .exists() - ) diff --git a/projects/views.py b/projects/views.py index 528cea12..aa252fc2 100644 --- a/projects/views.py +++ b/projects/views.py @@ -17,8 +17,7 @@ from rest_framework.views import APIView from core.permissions import IsStaffOrReadOnly -from core.serializers import SetLikedSerializer -from core.services import add_view, set_like +from core.services import add_view from partner_programs.models import ( PartnerProgram, PartnerProgramProject, @@ -38,14 +37,12 @@ Project, ProjectCompany, ProjectGoal, - ProjectNews, Resource, ) -from projects.pagination import ProjectNewsPagination, ProjectsPagination +from projects.pagination import ProjectsPagination from projects.permissions import ( CanBindProjectToProgram, HasInvolvementInProjectOrReadOnly, - IsNewsAuthorIsProjectLeaderOrReadOnly, IsProjectLeader, IsProjectLeaderOrReadOnly, IsProjectLeaderOrReadOnlyForNonDrafts, @@ -65,8 +62,6 @@ ProjectDuplicateRequestSerializer, ProjectGoalSerializer, ProjectListSerializer, - ProjectNewsDetailSerializer, - ProjectNewsListSerializer, ProjectSubscribersListSerializer, ResourceSerializer, ) @@ -356,104 +351,6 @@ def get(self, *args, **kwargs): return Response(serializer.data) -class ProjectNewsList(generics.ListCreateAPIView): - serializer_class = ProjectNewsListSerializer - permission_classes = [ProjectVisibilityPermission, IsNewsAuthorIsProjectLeaderOrReadOnly] - pagination_class = ProjectNewsPagination - - def perform_create(self, serializer): - project = Project.objects.get(pk=self.kwargs.get("project_pk")) - serializer.save(project=project) - - def get_queryset(self): - project = Project.objects.get(pk=self.kwargs.get("project_pk")) - return ProjectNews.objects.filter(project=project) - - def get(self, request, *args, **kwargs): - news = self.paginate_queryset(self.get_queryset()) - context = {"user": request.user} - serializer = ProjectNewsListSerializer(news, context=context, many=True) - return self.get_paginated_response(serializer.data) - - -class ProjectNewsDetail(generics.RetrieveUpdateDestroyAPIView): - queryset = ProjectNews.objects.all() - serializer_class = ProjectNewsDetailSerializer - permission_classes = [ProjectVisibilityPermission, IsNewsAuthorIsProjectLeaderOrReadOnly] - - def get_queryset(self): - try: - project = Project.objects.get(pk=self.kwargs.get("project_pk")) - return ProjectNews.objects.filter(project=project).all() - except Project.DoesNotExist: - return [] - - def get(self, request, *args, **kwargs): - try: - news = self.get_queryset().get(pk=self.kwargs["pk"]) - context = {"user": request.user} - return Response(ProjectNewsDetailSerializer(news, context=context).data) - except ProjectNews.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) - - def update(self, request, *args, **kwargs): - try: - news = self.get_queryset().get(pk=self.kwargs["pk"]) - context = {"user": request.user} - serializer = ProjectNewsDetailSerializer( - news, data=request.data, context=context - ) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data) - except ProjectNews.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) - - -class ProjectNewsDetailSetViewed(generics.CreateAPIView): - queryset = ProjectNews.objects.all() - # fixme - # serializer_class = SetViewedSerializer - permission_classes = [IsAuthenticated, ProjectVisibilityPermission] - - def get_queryset(self): - try: - project = Project.objects.get(pk=self.kwargs.get("project_pk")) - return ProjectNews.objects.filter(project=project).all() - except Project.DoesNotExist: - return [] - - def post(self, request, *args, **kwargs): - try: - news = self.get_queryset().get(pk=self.kwargs["pk"]) - add_view(news, request.user) - - return Response(status=status.HTTP_200_OK) - except ProjectNews.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) - - -class ProjectNewsDetailSetLiked(generics.CreateAPIView): - serializer_class = SetLikedSerializer - permission_classes = [IsAuthenticated, ProjectVisibilityPermission] - - def get_queryset(self): - try: - project = Project.objects.get(pk=self.kwargs["project_pk"]) - return ProjectNews.objects.filter(project=project).all() - except Project.DoesNotExist: - return [] - - def post(self, request, *args, **kwargs): - try: - news = self.get_queryset().get(pk=self.kwargs["pk"]) - set_like(news, request.user, request.data.get("is_liked")) - - return Response(status=status.HTTP_200_OK) - except ProjectNews.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) - - class ProjectSubscribers(APIView): permission_classes = [IsAuthenticated, ProjectVisibilityPermission] From 72aa82db9199bbed4f9ca10e728011fbcb236d47 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 22 May 2026 10:51:45 +0500 Subject: [PATCH 10/32] =?UTF-8?q?=D0=A3=D1=81=D0=B8=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20dev?= =?UTF-8?q?=20deploy=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-ci.yml | 62 +++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index a52c6ede..0d05b3d5 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -53,6 +53,23 @@ jobs: docker compose -f docker-compose.dev-ci.yml build web && docker compose -f docker-compose.dev-ci.yml run --rm web python manage.py migrate && docker compose -f docker-compose.dev-ci.yml up -d --force-recreate && + expected_image="procollab-dev-api:${IMAGE_TAG}" && + for service in web celerys; do + container="$(docker compose -f docker-compose.dev-ci.yml ps -q "$service")" + if [ -z "$container" ]; then + echo "Service ${service} has no running container" >&2 + docker compose -f docker-compose.dev-ci.yml ps >&2 || true + exit 1 + fi + + actual_image="$(docker inspect -f '{{.Config.Image}}' "$container")" + echo "Service ${service}: container=${container} image=${actual_image}" + if [ "$actual_image" != "$expected_image" ]; then + echo "Service ${service} uses unexpected image: ${actual_image}, expected ${expected_image}" >&2 + docker compose -f docker-compose.dev-ci.yml ps >&2 || true + exit 1 + fi + done && install -d /etc/nginx/procollab/includes && install -m 644 deploy/nginx/host/includes/proxy_app.inc /etc/nginx/procollab/includes/proxy_app.inc && @@ -83,28 +100,37 @@ jobs: exit 1 fi && - celery_status="" && - celery_ping="" && - for attempt in $(seq 1 24); do - celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" && + docker compose -f docker-compose.dev-ci.yml ps + + celery_status="" + celery_ping="" + celery_container="" + for attempt in $(seq 1 12); do + celery_container="$(docker compose -f docker-compose.dev-ci.yml ps -q celerys 2>/dev/null || true)" + if [ -n "$celery_container" ]; then + celery_status="$(docker inspect -f '{{.State.Status}}' "$celery_container" 2>/dev/null || true)" + else + celery_status="missing" + fi + + echo "Celery check attempt ${attempt}: container=${celery_container:-missing} status=${celery_status}" if [ "$celery_status" = "running" ]; then - celery_ping="$(docker compose -f docker-compose.dev-ci.yml exec -T celerys sh -lc 'celery -A procollab inspect ping --timeout=10' 2>&1 || true)" && - printf '%s\n' "$celery_ping" && + celery_ping="$(docker compose -f docker-compose.dev-ci.yml exec -T celerys sh -lc 'celery -A procollab inspect ping --timeout=15' 2>&1 || true)" + printf '%s\n' "$celery_ping" if printf '%s\n' "$celery_ping" | grep -q 'pong'; then - echo "Celery check passed on attempt ${attempt}" && + echo "Celery check passed on attempt ${attempt}" break fi - fi && + fi sleep 5 - done && - - if [ "$celery_status" != "running" ]; then - echo "Celery container is not running: ${celery_status}" >&2 && - exit 1 - fi && - - printf '%s\n' "$celery_ping" | grep -q 'pong' || { - echo "Celery ping failed" >&2 + done + + if [ "$celery_status" != "running" ] || ! printf '%s\n' "$celery_ping" | grep -q 'pong'; then + echo "Celery check failed: status=${celery_status}" >&2 + docker compose -f docker-compose.dev-ci.yml ps >&2 || true + docker compose -f docker-compose.dev-ci.yml logs --tail=200 celerys >&2 || true + docker compose -f docker-compose.dev-ci.yml logs --tail=100 redis >&2 || true + docker compose -f docker-compose.dev-ci.yml logs --tail=100 web >&2 || true exit 1 - } + fi From 0352782521d9e3bc139edd732851c2aecf4d2b46 Mon Sep 17 00:00:00 2001 From: Toksi Date: Mon, 25 May 2026 10:32:03 +0500 Subject: [PATCH 11/32] =?UTF-8?q?=D0=A3=D1=81=D0=B8=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20pro?= =?UTF-8?q?d=20deploy=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release-ci.yml | 48 ++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index 30bf0951..a6381219 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -184,7 +184,24 @@ jobs: docker compose -f docker-compose.prod-ci.yml -p prod pull web celerys docker compose -f docker-compose.prod-ci.yml -p prod run --rm web python manage.py migrate - docker compose -f docker-compose.prod-ci.yml -p prod up -d + docker compose -f docker-compose.prod-ci.yml -p prod up -d --force-recreate + expected_image="ghcr.io/procollab-github/api:${IMAGE_TAG}" + for service in web celerys; do + container="$(docker compose -f docker-compose.prod-ci.yml -p prod ps -q "$service")" + if [ -z "$container" ]; then + echo "Service ${service} has no running container" >&2 + docker compose -f docker-compose.prod-ci.yml -p prod ps >&2 || true + exit 1 + fi + + actual_image="$(docker inspect -f '{{.Config.Image}}' "$container")" + echo "Service ${service}: container=${container} image=${actual_image}" + if [ "$actual_image" != "$expected_image" ]; then + echo "Service ${service} uses unexpected image: ${actual_image}, expected ${expected_image}" >&2 + docker compose -f docker-compose.prod-ci.yml -p prod ps >&2 || true + exit 1 + fi + done if [ "$(id -u)" -eq 0 ]; then nginx -t systemctl reload nginx @@ -210,12 +227,22 @@ jobs: exit 1 fi + docker compose -f docker-compose.prod-ci.yml -p prod ps + celery_status="" celery_ping="" - for attempt in $(seq 1 24); do - celery_status="$(docker inspect -f '{{.State.Status}}' api_celery 2>/dev/null || true)" + celery_container="" + for attempt in $(seq 1 12); do + celery_container="$(docker compose -f docker-compose.prod-ci.yml -p prod ps -q celerys 2>/dev/null || true)" + if [ -n "$celery_container" ]; then + celery_status="$(docker inspect -f '{{.State.Status}}' "$celery_container" 2>/dev/null || true)" + else + celery_status="missing" + fi + + echo "Celery check attempt ${attempt}: container=${celery_container:-missing} status=${celery_status}" if [ "$celery_status" = "running" ]; then - celery_ping="$(docker compose -f docker-compose.prod-ci.yml -p prod exec -T celerys sh -lc 'celery -A procollab inspect ping --timeout=10' 2>&1 || true)" + celery_ping="$(docker compose -f docker-compose.prod-ci.yml -p prod exec -T celerys sh -lc 'celery -A procollab inspect ping --timeout=15' 2>&1 || true)" printf '%s\n' "$celery_ping" if printf '%s\n' "$celery_ping" | grep -q 'pong'; then echo "Celery check passed on attempt ${attempt}" @@ -226,12 +253,11 @@ jobs: sleep 5 done - if [ "$celery_status" != "running" ]; then - echo "Celery container is not running: ${celery_status}" >&2 + if [ "$celery_status" != "running" ] || ! printf '%s\n' "$celery_ping" | grep -q 'pong'; then + echo "Celery check failed: status=${celery_status}" >&2 + docker compose -f docker-compose.prod-ci.yml -p prod ps >&2 || true + docker compose -f docker-compose.prod-ci.yml -p prod logs --tail=200 celerys >&2 || true + docker compose -f docker-compose.prod-ci.yml -p prod logs --tail=100 redis >&2 || true + docker compose -f docker-compose.prod-ci.yml -p prod logs --tail=100 web >&2 || true exit 1 fi - - printf '%s\n' "$celery_ping" | grep -q 'pong' || { - echo "Celery ping failed" >&2 - exit 1 - } From 56e25cbe78be6cbca852910f2d30f2504aa25b9a Mon Sep 17 00:00:00 2001 From: Toksi Date: Mon, 25 May 2026 11:11:20 +0500 Subject: [PATCH 12/32] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC=D0=B8=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=20Feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/feed.md | 33 +++++++++++++----- feed/serializers.py | 2 -- feed/tests/helpers.py | 17 +++++++++ feed/tests/test_feed_api.py | 45 ++++++++++++++++++++++++ feed/tests/test_feed_signals.py | 62 +++++++++++++++++++++++++++++++++ feed/urls.py | 3 +- feed/views.py | 49 ++------------------------ 7 files changed, 152 insertions(+), 59 deletions(-) create mode 100644 feed/tests/helpers.py create mode 100644 feed/tests/test_feed_signals.py diff --git a/docs/modules/feed.md b/docs/modules/feed.md index e5635917..10d8f7e4 100644 --- a/docs/modules/feed.md +++ b/docs/modules/feed.md @@ -11,7 +11,8 @@ Feed отвечает за общую ленту `/feed/`. ## Статус модуля Модуль рабочий, но небольшой и связан с `news`, `projects` и `vacancy`. -Основная логика сосредоточена во view, serializer и service helpers. +Основная логика сосредоточена во view, serializer, service helpers и signal +handlers для служебных записей. ## Основные возможности @@ -31,8 +32,9 @@ Feed отвечает за общую ленту `/feed/`. - `feed/services.py` - helpers для лайков и служебных feed-записей. - `feed/mapping.py` - соответствие content object типам и serializers. - `feed/constants.py` - типы моделей, для которых signals создают feed-записи. -- `feed/signals.py` - подключение signal handlers. -- `feed/tests/` - regression-тесты API и service helpers. +- `feed/signals.py` - создание и удаление служебных feed-записей для проектов + и вакансий. +- `feed/tests/` - regression-тесты API, service helpers и signal handlers. ## Основные сценарии @@ -56,6 +58,10 @@ View выбирает подходящие `news.News`, сериализует Они используют `news.News` с пустым `text` и связью на объект, например проект или вакансию. +Для служебной записи проекта `type_model = "project"`, а `content` содержит +проект. Для служебной записи вакансии `type_model = "vacancy"`, а `content` +содержит вакансию. + ### 4. Проект становится недоступным для публичной ленты Если проект черновой или непубличный, связанные с ним записи не возвращаются в @@ -65,17 +71,18 @@ View выбирает подходящие `news.News`, сериализует - `GET /feed/?type=news` - новости пользователей. - `GET /feed/?type=project` - проектные новости и проектные feed-записи. -- `GET /feed/?type=project|news` - комбинированная выдача по нескольким типам. +- `GET /feed/?type=vacancy` - служебные feed-записи вакансий. +- `GET /feed/?type=project|news|vacancy` - комбинированная выдача по нескольким + типам. ## Ограничения и правила - Feed читает данные из `news.News`, но не отвечает за создание обычных project/user/program news. - Служебная feed-запись определяется через пустой `text`. -- Signals проектов могут создавать или удалять feed-записи, но тесты этих - side effects остаются в модуле `projects`. -- `DevScript` в `feed/views.py` остается служебным legacy-инструментом и не - является основным пользовательским API. +- Signals `feed` создают или удаляют служебные feed-записи для проектов и + вакансий. Более широкие сценарии публикации проекта остаются в модуле + `projects`. ## Тесты @@ -83,8 +90,16 @@ View выбирает подходящие `news.News`, сериализует - `/feed/?type=news` возвращает пользовательские новости; - `/feed/?type=project` возвращает проектные новости в frontend-формате; +- `/feed/?type=project` возвращает служебную feed-запись проекта как + `type_model = "project"`; +- `/feed/?type=vacancy` возвращает служебную feed-запись вакансии как + `type_model = "vacancy"`; - новости непубличных проектов не попадают в feed; +- новости черновых проектов не попадают в feed; +- liked flag выставляется для новостей, лайкнутых текущим пользователем; - `get_liked_news()` возвращает лайкнутые текущим пользователем записи; - `create_news_for_model()` создает одну служебную feed-запись без дублей; - `delete_news_for_model()` удаляет только служебную feed-запись и не трогает - обычную новость с текстом. + обычную новость с текстом; +- signal handlers создают и удаляют служебные feed-записи при изменении проекта + или вакансии. diff --git a/feed/serializers.py b/feed/serializers.py index 3ce5fa57..af18e5da 100644 --- a/feed/serializers.py +++ b/feed/serializers.py @@ -28,8 +28,6 @@ def get_type_model(self, obj) -> str: def get_content_object(self, obj) -> dict: type_model = obj.content_type.model - if is_content_news(obj) and self.get_type_model(obj) == "project": - type_model = "news" serializer = CONTENT_OBJECT_SERIALIZER_MAPPING[type_model](obj.content_object) return serializer.data diff --git a/feed/tests/helpers.py b/feed/tests/helpers.py new file mode 100644 index 00000000..c1ca6010 --- /dev/null +++ b/feed/tests/helpers.py @@ -0,0 +1,17 @@ +from news.tests.helpers import create_project +from projects.models import Project +from vacancy.models import Vacancy + + +def create_vacancy( + *, + project: Project | None = None, + role: str = "Feed vacancy", + is_active: bool = True, +) -> Vacancy: + return Vacancy.objects.create( + project=project or create_project(name="Feed vacancy project"), + role=role, + description="Vacancy description", + is_active=is_active, + ) diff --git a/feed/tests/test_feed_api.py b/feed/tests/test_feed_api.py index 304a6351..27eed742 100644 --- a/feed/tests/test_feed_api.py +++ b/feed/tests/test_feed_api.py @@ -1,6 +1,9 @@ from django.test import TestCase from rest_framework.test import APIClient +from core.services import set_like +from feed.services import create_news_for_model +from feed.tests.helpers import create_vacancy from news.tests.helpers import create_news_for, create_project, create_user @@ -33,6 +36,39 @@ def test_feed_returns_project_news_as_news_content(self): self.assertEqual(item["content"]["id"], news.id) self.assertEqual(item["content"]["text"], "Project feed news") + def test_feed_returns_project_feed_record_as_project_content(self): + project = create_project(name="Feed record project") + create_news_for_model(project) + + response = self.client.get("/feed/?type=project") + + self.assertEqual(response.status_code, 200) + item = response.data["results"][0] + self.assertEqual(item["type_model"], "project") + self.assertEqual(item["content"]["id"], project.id) + + def test_feed_returns_vacancy_feed_record_as_vacancy_content(self): + vacancy = create_vacancy(role="Backend developer") + + response = self.client.get("/feed/?type=vacancy") + + self.assertEqual(response.status_code, 200) + item = response.data["results"][0] + self.assertEqual(item["type_model"], "vacancy") + self.assertEqual(item["content"]["id"], vacancy.id) + self.assertEqual(item["content"]["role"], "Backend developer") + + def test_feed_marks_news_liked_by_current_user(self): + news = create_news_for(self.user, text="Liked user feed news") + set_like(news, self.user, True) + + response = self.client.get("/feed/?type=news") + + self.assertEqual(response.status_code, 200) + item = response.data["results"][0] + self.assertEqual(item["type_model"], "news") + self.assertTrue(item["content"]["is_user_liked"]) + def test_feed_excludes_news_for_private_project(self): private_project = create_project(name="Private project", is_public=False) create_news_for(private_project, text="Private project news") @@ -41,3 +77,12 @@ def test_feed_excludes_news_for_private_project(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["results"], []) + + def test_feed_excludes_news_for_draft_project(self): + draft_project = create_project(name="Draft project", draft=True) + create_news_for(draft_project, text="Draft project news") + + response = self.client.get("/feed/?type=project") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"], []) diff --git a/feed/tests/test_feed_signals.py b/feed/tests/test_feed_signals.py new file mode 100644 index 00000000..5768accf --- /dev/null +++ b/feed/tests/test_feed_signals.py @@ -0,0 +1,62 @@ +from django.test import TestCase + +from feed.tests.helpers import create_vacancy +from news.models import News +from news.services import FEED_RECORD_TEXT +from news.tests.helpers import create_project + + +class FeedSignalTests(TestCase): + def test_project_signal_removes_feed_record_when_project_becomes_draft(self): + project = create_project(name="Draft signal project") + self.assertTrue( + News.objects.get_news(project).filter(text=FEED_RECORD_TEXT).exists() + ) + + project.draft = True + project.save(update_fields=["draft"]) + + self.assertFalse( + News.objects.get_news(project).filter(text=FEED_RECORD_TEXT).exists() + ) + + def test_project_delete_signal_removes_feed_record(self): + project = create_project(name="Deleted signal project") + project_id = project.id + + project.delete() + + self.assertFalse( + News.objects.filter( + object_id=project_id, + content_type__model="project", + text=FEED_RECORD_TEXT, + ).exists() + ) + + def test_vacancy_signal_creates_and_removes_feed_record_by_active_state(self): + vacancy = create_vacancy(role="Signal vacancy") + self.assertTrue( + News.objects.get_news(vacancy).filter(text=FEED_RECORD_TEXT).exists() + ) + + vacancy.is_active = False + vacancy.save(update_fields=["is_active"]) + + self.assertFalse( + News.objects.get_news(vacancy).filter(text=FEED_RECORD_TEXT).exists() + ) + + def test_vacancy_delete_signal_removes_feed_record(self): + vacancy = create_vacancy(role="Deleted signal vacancy") + vacancy_id = vacancy.id + + vacancy.delete() + + self.assertFalse( + News.objects.filter( + object_id=vacancy_id, + content_type__model="vacancy", + text=FEED_RECORD_TEXT, + ).exists() + ) diff --git a/feed/urls.py b/feed/urls.py index ef4d9075..0a63a076 100644 --- a/feed/urls.py +++ b/feed/urls.py @@ -1,10 +1,9 @@ from django.urls import path -from feed.views import NewSimpleFeed, DevScript +from feed.views import NewSimpleFeed app_name = "feed" urlpatterns = [ path("", NewSimpleFeed.as_view()), - path("dev-needs-script", DevScript.as_view()), ] diff --git a/feed/views.py b/feed/views.py index 07264b4c..1a0ee4f7 100644 --- a/feed/views.py +++ b/feed/views.py @@ -1,10 +1,6 @@ -from django.contrib.contenttypes.models import ContentType from django.db.models import Q, QuerySet -from rest_framework.generics import CreateAPIView -from rest_framework.response import Response from rest_framework.views import APIView -from core.serializers import EmptySerializer from feed.pagination import FeedPagination from feed.services import get_liked_news from news.models import News @@ -67,52 +63,13 @@ def get(self, *args, **kwargs): # временная подстройка данных под фронт for data in serializer.data: if data["type_model"] in ["project", "vacancy", None]: - fomated_data = { + formatted_data = { "type_model": data["type_model"], "content": data["content_object"], } elif data["type_model"] == "news": del data["type_model"] - fomated_data = {"type_model": "news", "content": data} - new_data.append(fomated_data) + formatted_data = {"type_model": "news", "content": data} + new_data.append(formatted_data) return paginator.get_paginated_response(new_data) - - -class DevScript(CreateAPIView): - serializer_class = EmptySerializer - - def create(self, request): - content_type_project = ContentType.objects.filter(model="project").first() - for project in Project.objects.filter(draft=False): - if not News.objects.filter( - content_type=content_type_project, object_id=project.id - ).exists(): - News.objects.create( - content_type=content_type_project, - object_id=project.id, - datetime_created=project.datetime_created, - ) - - content_type_vacancy = ContentType.objects.filter(model="vacancy").first() - for vacancy in Vacancy.objects.filter(is_active=True): - if not News.objects.filter( - content_type=content_type_vacancy, object_id=vacancy.id - ).exists(): - News.objects.create( - content_type=content_type_vacancy, - object_id=vacancy.id, - datetime_created=vacancy.datetime_created, - ) - - news_to_delete = list( - News.objects.filter( - content_type__in=[content_type_vacancy, content_type_project] - ) - ) - - for news in news_to_delete: - if not news.content_object: - news.delete() - - return Response({"status": "success"}, status=201) From cad8bf72e08cea84517e4af369de992d05b67c4f Mon Sep 17 00:00:00 2001 From: Toksi Date: Mon, 25 May 2026 11:43:57 +0500 Subject: [PATCH 13/32] =?UTF-8?q?=D0=A3=D1=81=D0=B8=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B2=20Feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/feed.md | 9 +++++++++ feed/tests/test_feed_api.py | 30 ++++++++++++++++++++++++++++++ feed/views.py | 6 +++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/modules/feed.md b/docs/modules/feed.md index 10d8f7e4..76dd5d01 100644 --- a/docs/modules/feed.md +++ b/docs/modules/feed.md @@ -21,6 +21,7 @@ handlers для служебных записей. - отображение проектных новостей; - отображение служебных записей для проектов и вакансий; - исключение записей непубличных или черновых проектов; +- исключение записей закрытых вакансий и вакансий из недоступных проектов; - передача признака лайка текущим пользователем. ## Архитектура @@ -67,6 +68,11 @@ View выбирает подходящие `news.News`, сериализует Если проект черновой или непубличный, связанные с ним записи не возвращаются в `/feed/`. +### 5. Вакансия становится недоступной для публичной ленты + +Если вакансия закрыта, находится в черновом проекте или относится к непубличному +проекту, связанная с ней служебная feed-запись не возвращается в `/feed/`. + ## API - `GET /feed/?type=news` - новости пользователей. @@ -96,6 +102,9 @@ View выбирает подходящие `news.News`, сериализует `type_model = "vacancy"`; - новости непубличных проектов не попадают в feed; - новости черновых проектов не попадают в feed; +- служебные записи закрытых вакансий не попадают в feed; +- служебные записи вакансий из черновых и непубличных проектов не попадают в + feed; - liked flag выставляется для новостей, лайкнутых текущим пользователем; - `get_liked_news()` возвращает лайкнутые текущим пользователем записи; - `create_news_for_model()` создает одну служебную feed-запись без дублей; diff --git a/feed/tests/test_feed_api.py b/feed/tests/test_feed_api.py index 27eed742..95e422a4 100644 --- a/feed/tests/test_feed_api.py +++ b/feed/tests/test_feed_api.py @@ -58,6 +58,36 @@ def test_feed_returns_vacancy_feed_record_as_vacancy_content(self): self.assertEqual(item["content"]["id"], vacancy.id) self.assertEqual(item["content"]["role"], "Backend developer") + def test_feed_excludes_feed_record_for_inactive_vacancy(self): + vacancy = create_vacancy(role="Inactive vacancy", is_active=False) + create_news_for_model(vacancy) + + response = self.client.get("/feed/?type=vacancy") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"], []) + + def test_feed_excludes_vacancy_feed_record_for_draft_project(self): + draft_project = create_project(name="Draft vacancy project", draft=True) + create_vacancy(project=draft_project, role="Draft project vacancy") + + response = self.client.get("/feed/?type=vacancy") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"], []) + + def test_feed_excludes_vacancy_feed_record_for_private_project(self): + private_project = create_project( + name="Private vacancy project", + is_public=False, + ) + create_vacancy(project=private_project, role="Private project vacancy") + + response = self.client.get("/feed/?type=vacancy") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"], []) + def test_feed_marks_news_liked_by_current_user(self): news = create_news_for(self.user, text="Liked user feed news") set_like(news, self.user, True) diff --git a/feed/views.py b/feed/views.py index 1a0ee4f7..606c44c0 100644 --- a/feed/views.py +++ b/feed/views.py @@ -37,7 +37,11 @@ def get_queryset(self) -> QuerySet[News]: "project": Project.objects.filter(draft=False, is_public=True).values_list( "id", flat=True ), - "vacancy": Vacancy.objects.values_list("id", flat=True), + "vacancy": Vacancy.objects.filter( + is_active=True, + project__draft=False, + project__is_public=True, + ).values_list("id", flat=True), } for model_name, ids_queryset in existing_object_filters.items(): queryset = queryset.exclude( From 20175018e987ec1697fab5ead339046b5d8840a2 Mon Sep 17 00:00:00 2001 From: Toksi Date: Mon, 25 May 2026 12:38:32 +0500 Subject: [PATCH 14/32] =?UTF-8?q?=D0=A3=D1=82=D0=BE=D1=87=D0=BD=D1=91?= =?UTF-8?q?=D0=BD=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20API=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=20New?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/news.md | 14 ++---- news/serializers.py | 70 +++++++++++++++++++--------- news/tests/test_news_project_api.py | 43 ++++++++++++++++- news/urls.py | 11 ----- news/views.py | 71 +++++++++++++++++++++++------ procollab/urls.py | 1 - 6 files changed, 153 insertions(+), 57 deletions(-) delete mode 100644 news/urls.py diff --git a/docs/modules/news.md b/docs/modules/news.md index ec2d9e17..3ca99eab 100644 --- a/docs/modules/news.md +++ b/docs/modules/news.md @@ -47,10 +47,10 @@ generic relation: различения обычной новости и feed-записи. - `news/querysets.py` - явные queryset helpers по контексту URL: project, user или partner program. -- `news/views.py` - общий API для list/create/detail/update/delete, set_viewed и - set_liked. -- `news/serializers.py` - request/response serializers для создания, списка и - detail. +- `news/views.py` - контекстный API для list/create/detail/update/delete, + set_viewed и set_liked. +- `news/serializers.py` - request serializers и response serializers, + разделенные по контекстам project/user/program. - `news/permissions.py` - права на создание и изменение новости в зависимости от связанного объекта. - `news/admin.py` - админка `News`. @@ -89,9 +89,6 @@ Feed-запись определяется через helper `is_feed_record(new Связанные endpoints: -- `GET /news/` - подключен напрямую, но без контекста возвращает пустой список. -- `GET /news//` - подключен напрямую, но без контекста не является - основным пользовательским сценарием. - `GET /feed/?type=...` - общая лента, которая читает данные из `News`. ## Основные сценарии @@ -143,8 +140,7 @@ Feed-запись определяется через helper `is_feed_record(new - Новость проекта может создавать и изменять только лидер проекта. - Новость пользователя может создавать и изменять только сам пользователь. - Новость программы может создавать и изменять только менеджер программы. -- Прямой `/news/` без project/user/program context не является основным - пользовательским API. +- Вложения новости должны ссылаться только на `UserFile` текущего пользователя. - Несуществующий project/user/program context возвращает `404`. - Проектные новости реализованы через `news.News`. diff --git a/news/serializers.py b/news/serializers.py index b395e901..e0aa69a2 100644 --- a/news/serializers.py +++ b/news/serializers.py @@ -2,6 +2,7 @@ from rest_framework import serializers from core.services import is_fan, get_likes_count, get_views_count +from files.models import UserFile from files.serializers import UserFileSerializer from news.mapping import NewsMapping from news.models import News @@ -10,7 +11,20 @@ User = get_user_model() -class NewsCreateSerializer(serializers.ModelSerializer[News]): +class NewsInputSerializer(serializers.ModelSerializer[News]): + files = serializers.PrimaryKeyRelatedField( + queryset=UserFile.objects.none(), + many=True, + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + request = self.context.get("request") + user = getattr(request, "user", None) + if user and user.is_authenticated: + self.fields["files"].queryset = UserFile.objects.filter(user=user) + class Meta: model = News fields = [ @@ -19,13 +33,20 @@ class Meta: ] -class NewsListResponseSerializer(serializers.ModelSerializer[News]): +class NewsCreateSerializer(NewsInputSerializer): + pass + + +class NewsUpdateSerializer(NewsInputSerializer): + pass + + +class BaseNewsResponseSerializer(serializers.ModelSerializer[News]): views_count = serializers.SerializerMethodField() likes_count = serializers.SerializerMethodField() name = serializers.SerializerMethodField() image_address = serializers.SerializerMethodField() is_user_liked = serializers.SerializerMethodField() - files = UserFileSerializer(many=True) def get_name(self, obj): return NewsMapping.get_name(obj.content_object) @@ -45,6 +66,10 @@ def get_is_user_liked(self, obj): return is_fan(obj, user) return False + +class BaseNewsListResponseSerializer(BaseNewsResponseSerializer): + files = UserFileSerializer(many=True) + class Meta: model = News fields = [ @@ -62,30 +87,19 @@ class Meta: read_only_fields = ["pin"] -class NewsDetailResponseSerializer(serializers.ModelSerializer): - views_count = serializers.SerializerMethodField() - likes_count = serializers.SerializerMethodField() - name = serializers.SerializerMethodField() - image_address = serializers.SerializerMethodField() - is_user_liked = serializers.SerializerMethodField() +class ProjectNewsListResponseSerializer(BaseNewsListResponseSerializer): + pass - def get_name(self, obj): - return NewsMapping.get_name(obj.content_object) - def get_image_address(self, obj): - return NewsMapping.get_image_address(obj.content_object) +class UserNewsListResponseSerializer(BaseNewsListResponseSerializer): + pass - def get_views_count(self, obj): - return get_views_count(obj) - def get_likes_count(self, obj): - return get_likes_count(obj) +class ProgramNewsListResponseSerializer(BaseNewsListResponseSerializer): + pass - def get_is_user_liked(self, obj): - user = self.context.get("user") - if user: - return is_fan(obj, user) - return False + +class BaseNewsDetailResponseSerializer(BaseNewsResponseSerializer): class Meta: model = News @@ -103,3 +117,15 @@ class Meta: "files", ] read_only_fields = ["pin"] + + +class ProjectNewsDetailResponseSerializer(BaseNewsDetailResponseSerializer): + pass + + +class UserNewsDetailResponseSerializer(BaseNewsDetailResponseSerializer): + pass + + +class ProgramNewsDetailResponseSerializer(BaseNewsDetailResponseSerializer): + pass diff --git a/news/tests/test_news_project_api.py b/news/tests/test_news_project_api.py index 2ec63f25..5f15f70f 100644 --- a/news/tests/test_news_project_api.py +++ b/news/tests/test_news_project_api.py @@ -5,7 +5,7 @@ from core.models import Like, View from news.models import News -from .helpers import create_news_for, create_project, create_user +from .helpers import create_news_for, create_project, create_user, create_user_file class ProjectScopedNewsAPITests(TestCase): @@ -28,6 +28,27 @@ def test_project_leader_can_create_project_news(self): self.assertEqual(news.content_object, self.project) self.assertEqual(news.text, "Project news") + def test_project_news_create_rejects_file_of_another_user(self): + self.client.force_authenticate(self.leader) + another_user = create_user(prefix="project-news-file-owner") + another_user_file = create_user_file(another_user) + + response = self.client.post( + f"/projects/{self.project.id}/news/", + { + "text": "Project news with forbidden file", + "files": [another_user_file.link], + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse( + News.objects.get_news(self.project) + .filter(text="Project news with forbidden file") + .exists() + ) + def test_non_leader_cannot_create_project_news(self): self.client.force_authenticate(create_user(prefix="project-news-outsider")) @@ -78,6 +99,26 @@ def test_project_news_detail_can_be_updated_and_deleted_by_leader(self): self.assertEqual(delete_response.status_code, 204) self.assertFalse(News.objects.filter(pk=news.id).exists()) + def test_project_news_update_rejects_file_of_another_user(self): + self.client.force_authenticate(self.leader) + leader_file = create_user_file(self.leader, name="leader-file") + another_user = create_user(prefix="project-news-update-file-owner") + another_user_file = create_user_file(another_user, name="foreign-file") + news = create_news_for( + self.project, + text="Initial text", + files=[leader_file], + ) + + response = self.client.patch( + f"/projects/{self.project.id}/news/{news.id}/", + {"files": [another_user_file.link]}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(list(news.files.all()), [leader_file]) + def test_project_news_can_be_marked_viewed_and_liked(self): user = create_user(prefix="project-news-reader") self.client.force_authenticate(user) diff --git a/news/urls.py b/news/urls.py deleted file mode 100644 index 8eb60db0..00000000 --- a/news/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path - - -from news.views import NewsList, NewsDetail - -app_name = "news" - -urlpatterns = [ - path("", NewsList.as_view()), - path("/", NewsDetail.as_view()), -] diff --git a/news/views.py b/news/views.py index ae1e6ddb..e7c5c4be 100644 --- a/news/views.py +++ b/news/views.py @@ -12,8 +12,13 @@ from news.querysets import get_news_queryset_for_context from news.serializers import ( NewsCreateSerializer, - NewsDetailResponseSerializer, - NewsListResponseSerializer, + NewsUpdateSerializer, + ProgramNewsDetailResponseSerializer, + ProgramNewsListResponseSerializer, + ProjectNewsDetailResponseSerializer, + ProjectNewsListResponseSerializer, + UserNewsDetailResponseSerializer, + UserNewsListResponseSerializer, ) from news.services import ( create_program_news, @@ -27,6 +32,19 @@ User = get_user_model() +LIST_RESPONSE_SERIALIZERS = { + "project": ProjectNewsListResponseSerializer, + "user": UserNewsListResponseSerializer, + "program": ProgramNewsListResponseSerializer, +} + +DETAIL_RESPONSE_SERIALIZERS = { + "project": ProjectNewsDetailResponseSerializer, + "user": UserNewsDetailResponseSerializer, + "program": ProgramNewsDetailResponseSerializer, +} + + class ContextNewsAPIView: def get_queryset(self): return get_news_queryset_for_context(self.kwargs) @@ -34,14 +52,32 @@ def get_queryset(self): def get_news_object(self): return get_object_or_404(self.get_queryset(), pk=self.kwargs["pk"]) + def get_news_context(self): + if self.kwargs.get("project_pk") is not None: + return "project" + if self.kwargs.get("user_pk") is not None: + return "user" + if self.kwargs.get("partnerprogram_pk") is not None: + return "program" + return None + + def get_list_response_serializer_class(self): + return LIST_RESPONSE_SERIALIZERS[self.get_news_context()] + + def get_detail_response_serializer_class(self): + return DETAIL_RESPONSE_SERIALIZERS[self.get_news_context()] + class NewsList(ContextNewsAPIView, generics.ListCreateAPIView): - serializer_class = NewsListResponseSerializer + serializer_class = ProjectNewsListResponseSerializer permission_classes = [ProjectVisibilityPermission, IsNewsCreatorOrReadOnly] pagination_class = NewsPagination def post(self, request: Request, *args, **kwargs) -> Response: - serializer = NewsCreateSerializer(data=request.data) + serializer = NewsCreateSerializer( + data=request.data, + context={"request": request}, + ) serializer.is_valid(raise_exception=True) data = serializer.validated_data @@ -49,14 +85,14 @@ def post(self, request: Request, *args, **kwargs) -> Response: project = get_object_or_404(Project, pk=kwargs["project_pk"]) news = create_project_news(project, request.user, data) return Response( - NewsDetailResponseSerializer(news).data, + self.get_detail_response_serializer_class()(news).data, status=status.HTTP_201_CREATED, ) if kwargs.get("user_pk"): user = get_object_or_404(User, pk=kwargs["user_pk"]) news = create_user_news(user, request.user, data) return Response( - NewsDetailResponseSerializer(news).data, + self.get_detail_response_serializer_class()(news).data, status=status.HTTP_201_CREATED, ) @@ -64,7 +100,7 @@ def post(self, request: Request, *args, **kwargs) -> Response: program = get_object_or_404(PartnerProgram, pk=kwargs["partnerprogram_pk"]) news = create_program_news(program, request.user, data) return Response( - NewsDetailResponseSerializer(news).data, + self.get_detail_response_serializer_class()(news).data, status=status.HTTP_201_CREATED, ) return Response(status=status.HTTP_400_BAD_REQUEST) @@ -72,30 +108,39 @@ def post(self, request: Request, *args, **kwargs) -> Response: def get(self, request: Request, *args, **kwargs) -> Response: news = self.paginate_queryset(self.get_queryset()) context = {"user": request.user} - serializer = NewsListResponseSerializer(news, context=context, many=True) + serializer = self.get_list_response_serializer_class()( + news, + context=context, + many=True, + ) return self.get_paginated_response(serializer.data) class NewsDetail(ContextNewsAPIView, generics.RetrieveUpdateDestroyAPIView): - serializer_class = NewsDetailResponseSerializer + serializer_class = ProjectNewsDetailResponseSerializer permission_classes = [ProjectVisibilityPermission, IsNewsCreatorOrReadOnly] def get(self, request: Request, *args, **kwargs) -> Response: news = self.get_news_object() context = {"user": request.user} - return Response(NewsDetailResponseSerializer(news, context=context).data) + return Response( + self.get_detail_response_serializer_class()(news, context=context).data + ) def update(self, request: Request, *args, **kwargs) -> Response: news = self.get_news_object() context = {"user": request.user} - serializer = NewsDetailResponseSerializer( + serializer = NewsUpdateSerializer( news, data=request.data, - context=context, + context={"request": request}, + partial=kwargs.get("partial", False), ) serializer.is_valid(raise_exception=True) serializer.save() - return Response(serializer.data) + return Response( + self.get_detail_response_serializer_class()(news, context=context).data + ) class NewsDetailSetViewed(ContextNewsAPIView, generics.CreateAPIView): diff --git a/procollab/urls.py b/procollab/urls.py index 7cb68bba..b0b75eea 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -45,7 +45,6 @@ ), path("files/", include("files.urls", namespace="files")), path("industries/", include("industries.urls", namespace="industries")), - path("news/", include("news.urls", namespace="news")), path("projects/", include("projects.urls", namespace="projects")), path("vacancies/", include("vacancy.urls", namespace="vacancies")), path("core/", include("core.urls", namespace="core")), From 69e4e8001f20d02090f7aacfa29f359376d8e8dd Mon Sep 17 00:00:00 2001 From: Toksi Date: Tue, 26 May 2026 12:53:40 +0500 Subject: [PATCH 15/32] =?UTF-8?q?=D0=A3=D1=82=D0=BE=D1=87=D0=BD=D1=91?= =?UTF-8?q?=D0=BD=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20Fee?= =?UTF-8?q?d=20=D0=B8=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=BE=D0=B2=D0=BE=D1=81=D1=82=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/feed.md | 34 +++++++++++++----- feed/mapping.py | 3 +- feed/serializers.py | 35 +++++++++++++++--- feed/tests/test_feed_api.py | 72 ++++++++++++++++++++++++++++++++++++- feed/views.py | 22 +++--------- 5 files changed, 132 insertions(+), 34 deletions(-) diff --git a/docs/modules/feed.md b/docs/modules/feed.md index 76dd5d01..3b7cda7e 100644 --- a/docs/modules/feed.md +++ b/docs/modules/feed.md @@ -26,10 +26,9 @@ handlers для служебных записей. ## Архитектура -- `feed/views.py` - endpoint `/feed/`, фильтрация по типам и финальная сборка - response payload. -- `feed/serializers.py` - `FeedNewsResponseSerializer`, который превращает - `news.News` в элемент ленты. +- `feed/views.py` - endpoint `/feed/` и фильтрация по типам. +- `feed/serializers.py` - `FeedItemResponseSerializer`, который превращает + `news.News` в элемент ленты формата `{type_model, content}`. - `feed/services.py` - helpers для лайков и служебных feed-записей. - `feed/mapping.py` - соответствие content object типам и serializers. - `feed/constants.py` - типы моделей, для которых signals создают feed-записи. @@ -45,14 +44,28 @@ Frontend вызывает `/feed/?type=...`. View выбирает подходящие `news.News`, сериализует их и возвращает элементы ленты с полями `type_model` и `content`. +Ответ использует стандартную пагинацию DRF: `count`, `next`, `previous`, +`results`. Каждый элемент `results` содержит только: + +- `type_model` - тип элемента ленты; +- `content` - сериализованный объект, соответствующий этому типу. + ### 2. В ленту попадает обычная новость -Если `news.News` содержит текст и относится к пользователю или проекту, лента -возвращает ее как новость. +Если `news.News` содержит текст и относится к пользователю, проекту или +партнерской программе, лента возвращает ее как новость. Проектная новость с текстом возвращается как `type_model = "news"`, даже если ее `content_object` - проект. +Новость партнерской программы с текстом тоже возвращается как +`type_model = "news"`. Отдельный `type_model = "partner_program"` пока не +вводится. + +Служебные feed-записи партнерских программ сейчас не создаются. Если такой +сценарий понадобится, для него нужно отдельно согласовать `type_model` и +frontend-контракт. + ### 3. В ленту попадает служебная запись Служебные feed-записи создаются через `feed.services.create_news_for_model()`. @@ -78,14 +91,17 @@ View выбирает подходящие `news.News`, сериализует - `GET /feed/?type=news` - новости пользователей. - `GET /feed/?type=project` - проектные новости и проектные feed-записи. - `GET /feed/?type=vacancy` - служебные feed-записи вакансий. -- `GET /feed/?type=project|news|vacancy` - комбинированная выдача по нескольким - типам. +- `GET /feed/?type=partnerprogram` - новости партнерских программ. +- `GET /feed/?type=project|vacancy|news|partnerprogram` - комбинированная + выдача по нескольким типам. ## Ограничения и правила - Feed читает данные из `news.News`, но не отвечает за создание обычных project/user/program news. - Служебная feed-запись определяется через пустой `text`. +- Новости партнерских программ с текстом отображаются как обычные новости; + отдельные служебные карточки программ в ленте пока не поддерживаются. - Signals `feed` создают или удаляют служебные feed-записи для проектов и вакансий. Более широкие сценарии публикации проекта остаются в модуле `projects`. @@ -96,6 +112,8 @@ View выбирает подходящие `news.News`, сериализует - `/feed/?type=news` возвращает пользовательские новости; - `/feed/?type=project` возвращает проектные новости в frontend-формате; +- `/feed/?type=partnerprogram` возвращает новости программ как + `type_model = "news"`; - `/feed/?type=project` возвращает служебную feed-запись проекта как `type_model = "project"`; - `/feed/?type=vacancy` возвращает служебную feed-запись вакансии как diff --git a/feed/mapping.py b/feed/mapping.py index fdd12afb..0171ef1d 100644 --- a/feed/mapping.py +++ b/feed/mapping.py @@ -6,10 +6,9 @@ from vacancy.models import Vacancy from vacancy.serializers import VacancyDetailSerializer -CONTENT_OBJECT_MAPPING: dict[str, str | None] = { +CONTENT_OBJECT_MAPPING: dict[str, str] = { Project.__name__.lower(): "project", CustomUser.__name__.lower(): "news", - "partnerprogram": None, Vacancy.__name__.lower(): "vacancy", } diff --git a/feed/serializers.py b/feed/serializers.py index af18e5da..c412356b 100644 --- a/feed/serializers.py +++ b/feed/serializers.py @@ -6,11 +6,12 @@ from news.mapping import NewsMapping from news.models import News from news.services import is_content_news +from partner_programs.models import PartnerProgram from projects.models import Project from users.models import CustomUser -class FeedNewsResponseSerializer(serializers.ModelSerializer): +class FeedNewsContentSerializer(serializers.ModelSerializer): name = serializers.SerializerMethodField() image_address = serializers.SerializerMethodField() is_user_liked = serializers.SerializerMethodField() @@ -20,11 +21,18 @@ class FeedNewsResponseSerializer(serializers.ModelSerializer): content_object = serializers.SerializerMethodField() type_model = serializers.SerializerMethodField() - def get_type_model(self, obj) -> str: - model_type = CONTENT_OBJECT_MAPPING[obj.content_type.model] - if is_content_news(obj) and model_type == "project": + def get_type_model(self, obj) -> str | None: + content_model = obj.content_type.model + + if content_model == PartnerProgram.__name__.lower(): + # Новости программ сейчас отображаются как обычные новости. + # Отдельная служебная карточка программы в ленте пока не согласована. + return "news" if is_content_news(obj) else None + + if is_content_news(obj) and content_model == Project.__name__.lower(): return "news" - return model_type + + return CONTENT_OBJECT_MAPPING[content_model] def get_content_object(self, obj) -> dict: type_model = obj.content_type.model @@ -65,3 +73,20 @@ class Meta: "type_model", ] read_only_fields = ["views_count", "likes_count", "type_model"] + + +class FeedItemResponseSerializer(serializers.Serializer): + def to_representation(self, instance): + data = FeedNewsContentSerializer(instance, context=self.context).data + type_model = data["type_model"] + + if type_model == "news": + content = dict(data) + del content["type_model"] + else: + content = data["content_object"] + + return { + "type_model": type_model, + "content": content, + } diff --git a/feed/tests/test_feed_api.py b/feed/tests/test_feed_api.py index 95e422a4..12cbf199 100644 --- a/feed/tests/test_feed_api.py +++ b/feed/tests/test_feed_api.py @@ -4,7 +4,12 @@ from core.services import set_like from feed.services import create_news_for_model from feed.tests.helpers import create_vacancy -from news.tests.helpers import create_news_for, create_project, create_user +from news.tests.helpers import ( + create_news_for, + create_partner_program, + create_project, + create_user, +) class FeedAPITests(TestCase): @@ -20,6 +25,7 @@ def test_feed_returns_user_news_when_news_filter_requested(self): self.assertEqual(response.status_code, 200) item = response.data["results"][0] + self.assertEqual(set(item.keys()), {"type_model", "content"}) self.assertEqual(item["type_model"], "news") self.assertEqual(item["content"]["id"], news.id) self.assertEqual(item["content"]["text"], "User feed news") @@ -32,10 +38,24 @@ def test_feed_returns_project_news_as_news_content(self): self.assertEqual(response.status_code, 200) item = response.data["results"][0] + self.assertEqual(set(item.keys()), {"type_model", "content"}) self.assertEqual(item["type_model"], "news") self.assertEqual(item["content"]["id"], news.id) self.assertEqual(item["content"]["text"], "Project feed news") + def test_feed_returns_program_news_as_news_content(self): + program = create_partner_program(name="Feed program") + news = create_news_for(program, text="Program feed news") + + response = self.client.get("/feed/?type=partnerprogram") + + self.assertEqual(response.status_code, 200) + item = response.data["results"][0] + self.assertEqual(set(item.keys()), {"type_model", "content"}) + self.assertEqual(item["type_model"], "news") + self.assertEqual(item["content"]["id"], news.id) + self.assertEqual(item["content"]["text"], "Program feed news") + def test_feed_returns_project_feed_record_as_project_content(self): project = create_project(name="Feed record project") create_news_for_model(project) @@ -44,6 +64,7 @@ def test_feed_returns_project_feed_record_as_project_content(self): self.assertEqual(response.status_code, 200) item = response.data["results"][0] + self.assertEqual(set(item.keys()), {"type_model", "content"}) self.assertEqual(item["type_model"], "project") self.assertEqual(item["content"]["id"], project.id) @@ -54,10 +75,59 @@ def test_feed_returns_vacancy_feed_record_as_vacancy_content(self): self.assertEqual(response.status_code, 200) item = response.data["results"][0] + self.assertEqual(set(item.keys()), {"type_model", "content"}) self.assertEqual(item["type_model"], "vacancy") self.assertEqual(item["content"]["id"], vacancy.id) self.assertEqual(item["content"]["role"], "Backend developer") + def test_feed_combines_all_supported_filters(self): + project_news = create_news_for( + create_project(name="Combined project news"), + text="Combined project news", + ) + program_news = create_news_for( + create_partner_program(name="Combined program"), + text="Combined program news", + ) + user_news = create_news_for(self.user, text="Combined user news") + project = create_project(name="Combined project record") + vacancy = create_vacancy(role="Combined vacancy") + create_news_for_model(project) + + response = self.client.get( + "/feed/?type=project|vacancy|news|partnerprogram" + ) + + self.assertEqual(response.status_code, 200) + items_by_text = { + item["content"].get("text"): item + for item in response.data["results"] + if item["type_model"] == "news" + } + content_ids_by_type = { + type_model: { + item["content"]["id"] + for item in response.data["results"] + if item["type_model"] == type_model + } + for type_model in ["project", "vacancy"] + } + + self.assertEqual( + items_by_text[project_news.text]["content"]["id"], + project_news.id, + ) + self.assertEqual( + items_by_text[program_news.text]["content"]["id"], + program_news.id, + ) + self.assertEqual( + items_by_text[user_news.text]["content"]["id"], + user_news.id, + ) + self.assertIn(project.id, content_ids_by_type["project"]) + self.assertIn(vacancy.id, content_ids_by_type["vacancy"]) + def test_feed_excludes_feed_record_for_inactive_vacancy(self): vacancy = create_vacancy(role="Inactive vacancy", is_active=False) create_news_for_model(vacancy) diff --git a/feed/views.py b/feed/views.py index 606c44c0..324b8a29 100644 --- a/feed/views.py +++ b/feed/views.py @@ -7,11 +7,11 @@ from projects.models import Project from vacancy.models import Vacancy -from .serializers import FeedNewsResponseSerializer +from .serializers import FeedItemResponseSerializer class NewSimpleFeed(APIView): - serializator_class = FeedNewsResponseSerializer + serializer_class = FeedItemResponseSerializer pagination_class = FeedPagination def _get_filter_data(self) -> list[str]: @@ -54,7 +54,7 @@ def get_queryset(self) -> QuerySet[News]: def get(self, *args, **kwargs): paginator = self.pagination_class() paginated_data = paginator.paginate_queryset(self.get_queryset(), self.request) - serializer = FeedNewsResponseSerializer( + serializer = FeedItemResponseSerializer( paginated_data, context={ "user": self.request.user, @@ -62,18 +62,4 @@ def get(self, *args, **kwargs): }, many=True, ) - - new_data = [] - # временная подстройка данных под фронт - for data in serializer.data: - if data["type_model"] in ["project", "vacancy", None]: - formatted_data = { - "type_model": data["type_model"], - "content": data["content_object"], - } - elif data["type_model"] == "news": - del data["type_model"] - formatted_data = {"type_model": "news", "content": data} - new_data.append(formatted_data) - - return paginator.get_paginated_response(new_data) + return paginator.get_paginated_response(serializer.data) From 82c8badb73e1e92c987e8ecf6c4dfcab9af00d85 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 27 May 2026 10:31:04 +0500 Subject: [PATCH 16/32] =?UTF-8?q?=D0=94=D0=BB=D1=8F=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=20=D0=9F=D0=B0=D1=80=D1=82=D0=BD=D1=91=D1=80?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D1=85=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=BC=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B8=20=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 --- docs/modules/partner-programs.md | 203 ++++++- partner_programs/tests.py | 550 ------------------ partner_programs/tests/__init__.py | 0 partner_programs/tests/helpers.py | 141 +++++ partner_programs/tests/test_field_values.py | 216 +++++++ partner_programs/tests/test_program_api.py | 139 +++++ partner_programs/tests/test_program_detail.py | 102 ++++ .../tests/test_program_filters.py | 130 +++++ .../tests/test_program_project_submit.py | 78 +++ partner_programs/tests/test_project_apply.py | 168 ++++++ .../tests/test_project_field_values_api.py | 80 +++ partner_programs/tests/test_services.py | 98 ++++ 12 files changed, 1354 insertions(+), 551 deletions(-) delete mode 100644 partner_programs/tests.py create mode 100644 partner_programs/tests/__init__.py create mode 100644 partner_programs/tests/helpers.py create mode 100644 partner_programs/tests/test_field_values.py create mode 100644 partner_programs/tests/test_program_api.py create mode 100644 partner_programs/tests/test_program_detail.py create mode 100644 partner_programs/tests/test_program_filters.py create mode 100644 partner_programs/tests/test_program_project_submit.py create mode 100644 partner_programs/tests/test_project_apply.py create mode 100644 partner_programs/tests/test_project_field_values_api.py create mode 100644 partner_programs/tests/test_services.py diff --git a/docs/modules/partner-programs.md b/docs/modules/partner-programs.md index 575b483b..a1bdd35f 100644 --- a/docs/modules/partner-programs.md +++ b/docs/modules/partner-programs.md @@ -1,3 +1,204 @@ # Partner Programs -TODO +## Назначение + +Модуль `partner_programs` отвечает за партнерские программы: регистрацию +участников, привязку проектов к программе, дополнительные поля заявки, +сдачу проектов на проверку, фильтрацию и выгрузки проектов программы. + +Программа объединяет пользователей, проекты, новости, курсы и оценки проектов. + +## Статус модуля + +Модуль рабочий и используется продуктом, но пока остается архитектурно тяжелым: +значительная часть бизнес-логики находится во `views.py`, а тесты только +начинают приводиться к структуре, принятой для свежих модулей. + +## Основные возможности + +- список и детальная карточка партнерских программ; +- регистрация пользователя в программе; +- регистрация нового пользователя через внешнюю форму; +- просмотр материалов и связанных курсов программы; +- подача проекта в программу; +- заполнение дополнительных полей проекта в программе; +- сдача конкурсного проекта на проверку; +- список и фильтрация проектов программы для менеджеров; +- экспорт проектов и оценок; +- публикация проектов после окончания программы; +- новости программы через общий модуль `news`. + +## Архитектура + +- `partner_programs/models.py` - модели программ, участников, материалов, + проектов программы и дополнительных полей. +- `partner_programs/views.py` - API программы, регистрации, подачи проектов, + фильтров и экспортов. +- `partner_programs/serializers/` - request/response serializers и validation + дополнительных полей. +- `partner_programs/services.py` - сервисы публикации проектов и подготовки + данных для Excel-выгрузок. +- `partner_programs/selectors.py` - выборки участников для аналитики и + напоминаний. +- `partner_programs/permissions.py` - проверки менеджера программы, админа и + лидера проекта. +- `partner_programs/tasks.py` - celery-задача публикации проектов после + завершения программы. +- `partner_programs/tests/` - regression-тесты API, serializers и сервисов. + +## Ключевые сущности + +- `PartnerProgram` - сама программа, даты регистрации, подачи и завершения, + настройки конкурсности и публикации проектов. +- `PartnerProgramUserProfile` - участие пользователя в программе и данные + регистрационной формы. +- `PartnerProgramProject` - связь проекта с программой, статус сдачи проекта и + дата сдачи. +- `PartnerProgramField` - дополнительное поле программы. +- `PartnerProgramFieldValue` - значение дополнительного поля для проекта в + программе. +- `PartnerProgramMaterial` - материал программы: ссылка или файл. + +## API + +- `GET /programs/` - список опубликованных программ. +- `GET /programs/?participating=1` - активные программы текущего участника. +- `GET /programs//` - детальная карточка программы. +- `GET /programs//schema/` - схема регистрационных данных программы. +- `POST /programs//register/` - регистрация текущего пользователя в + программе. +- `POST /programs//register_new/` - регистрация нового пользователя через + внешнюю форму. +- `POST /programs//set_liked/` - лайк программы. +- `POST /programs//set_viewed/` - просмотр программы. +- `GET /programs//news/` - новости программы. +- `POST /programs//news/` - создание новости программы менеджером. +- `GET /programs//projects/apply/` - данные формы подачи проекта. +- `POST /programs//projects/apply/` - подача проекта в программу. +- `POST /programs/partner-program-projects//submit/` - сдача проекта + на проверку. +- `PUT /projects//program-fields/` - обновление дополнительных + полей проекта в программе. +- `GET /programs//filters/` - доступные фильтры проектов программы. +- `POST /programs//projects/filter/` - фильтрация проектов по + дополнительным полям. +- `GET /programs//projects/` - список проектов программы. +- `GET /programs//export-projects/` - Excel-выгрузка проектов. +- `GET /programs//export-rates/` - Excel-выгрузка оценок. + +## Основные сценарии + +### 1. Пользователь смотрит список программ + +Список `/programs/` показывает только программы с `draft = False`. +Для авторизованного пользователя response дополнительно показывает признак +`is_user_member`. + +Фильтр `participating=1` возвращает только активные программы, в которых +текущий пользователь является участником. + +### 2. Пользователь открывает программу + +Детальная карточка возвращает разные поля для участника и не-участника. +Если пользователь авторизован, просмотр программы фиксируется через общий +механизм просмотров. + +Связанные курсы программы возвращаются в поле `courses`; для каждого курса +указывается `is_available`. + +### 3. Пользователь регистрируется в программе + +`POST /programs//register/` создает `PartnerProgramUserProfile` для текущего +пользователя и сохраняет переданные данные формы в `partner_program_data`. + +Если срок регистрации завершен или пользователь уже зарегистрирован, API +возвращает ошибку. + +### 4. Пользователь подает проект в программу + +Участник программы открывает форму `/programs//projects/apply/`, получает +список дополнительных полей и отправляет проект. + +При успешной подаче создаются: + +- черновой непубличный `Project`; +- связь `PartnerProgramProject`; +- значения `PartnerProgramFieldValue`; +- связь `PartnerProgramUserProfile.project` с новым проектом. + +Подача запрещена не-участнику, после дедлайна, при повторной подаче проекта и +при некорректных дополнительных полях. + +### 5. Лидер обновляет поля проекта в программе + +`PUT /projects//program-fields/` обновляет значения дополнительных +полей проекта. + +Изменять значения может только лидер проекта. Для конкурсной программы после +сдачи проекта на проверку редактирование блокируется. + +### 6. Лидер сдает проект на проверку + +`POST /programs/partner-program-projects//submit/` переводит связь +проекта с программой в `submitted = True` и заполняет `datetime_submitted`. + +Сдача доступна только лидеру проекта, только для конкурсных программ и только +до дедлайна подачи проектов. + +### 7. Менеджер работает с проектами программы + +Менеджер или администратор программы может: + +- смотреть список проектов программы; +- получать список доступных фильтров; +- фильтровать проекты по значениям дополнительных полей; +- выгружать проекты и оценки в Excel. + +## Связи с другими модулями + +- `projects` - проекты подаются в программу и связываются через + `PartnerProgramProject`. +- `users` - участники и менеджеры программы. +- `news` - новости программы создаются и читаются через общий news API. +- `feed` - текстовые новости программы могут попадать в общую ленту как + `type_model = "news"`. +- `courses` - курс может быть связан с программой и доступен участникам + программы. +- `project_rates` - оценки проектов используются в выгрузке результатов. +- `mailing` / `vacancy.tasks.send_email` - уведомления после регистрации в + программе. +- `files` - материалы программы могут ссылаться на `UserFile`. + +## Ограничения и правила + +- Дополнительные поля программы задаются через `PartnerProgramField`. +- Значения дополнительных полей хранятся в `PartnerProgramFieldValue`. +- После сдачи проекта в конкурсной программе значения полей редактировать + нельзя. +- Служебные feed-карточки программ пока не поддерживаются; новости программ в + feed отображаются как обычные новости. +- Основной API-код пока сосредоточен во `views.py`; перед крупным рефакторингом + нужно зафиксировать больше regression-тестов. + +## Тесты + +Текущие regression-тесты проверяют: + +- validation дополнительных полей: `text`, `textarea`, `checkbox`, `select`, + `radio`, `file`; +- список программ и фильтр `participating`; +- регистрацию текущего пользователя в программе; +- регистрацию нового пользователя через внешнюю форму; +- запрет повторной регистрации и регистрации после дедлайна; +- detail программы с курсами для участника и не-участника; +- подачу проекта участником программы; +- запрет подачи проекта не-участником, после дедлайна, с дублями полей, + незаполненными обязательными полями и полями другой программы; +- обновление дополнительных полей проекта лидером; +- запрет обновления полей не-лидером и после сдачи конкурсного проекта; +- сдачу проекта на проверку; +- запрет сдачи проекта не-лидером, после дедлайна и для неконкурсной программы; +- список фильтров программы; +- фильтрацию проектов программы по дополнительным полям; +- список проектов программы для менеджера; +- публикацию проектов после завершения программы. diff --git a/partner_programs/tests.py b/partner_programs/tests.py deleted file mode 100644 index 95cab2e5..00000000 --- a/partner_programs/tests.py +++ /dev/null @@ -1,550 +0,0 @@ -from django.contrib.auth import get_user_model -from django.test import TestCase -from django.utils import timezone -from rest_framework.test import APIRequestFactory, force_authenticate - -from courses.models import Course, CourseAccessType, CourseContentStatus -from partner_programs.models import ( - PartnerProgram, - PartnerProgramField, - PartnerProgramProject, - PartnerProgramUserProfile, -) -from partner_programs.serializers import PartnerProgramFieldValueUpdateSerializer -from partner_programs.services import publish_finished_program_projects -from partner_programs.views import PartnerProgramDetail, PartnerProgramProjectSubmitView -from projects.models import Project - - -class PartnerProgramFieldValueUpdateSerializerInvalidTests(TestCase): - def setUp(self): - now = timezone.now() - self.partner_program = PartnerProgram.objects.create( - name="Тестовая программа", - tag="test_tag", - description="Описание тестовой программы", - city="Москва", - image_address="https://example.com/image.png", - cover_image_address="https://example.com/cover.png", - advertisement_image_address="https://example.com/advertisement.png", - presentation_address="https://example.com/presentation.pdf", - data_schema={}, - draft=True, - projects_availability="all_users", - datetime_registration_ends=now + timezone.timedelta(days=30), - datetime_started=now, - datetime_finished=now + timezone.timedelta(days=60), - ) - - def make_field(self, field_type, is_required, options=None): - return PartnerProgramField.objects.create( - partner_program=self.partner_program, - name="test_field", - label="Test Field", - field_type=field_type, - is_required=is_required, - options="|".join(options) if options else "", - ) - - def test_required_text_field_empty(self): - field = self.make_field("text", is_required=True) - data = {"field_id": field.id, "value_text": ""} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn( - "Поле должно содержать текстовое значение.", str(serializer.errors) - ) - - def test_required_textarea_field_null(self): - field = self.make_field("textarea", is_required=True) - data = {"field_id": field.id, "value_text": None} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn( - "Поле должно содержать текстовое значение.", str(serializer.errors) - ) - - def test_checkbox_invalid_string(self): - field = self.make_field("checkbox", is_required=True) - data = {"field_id": field.id, "value_text": "maybe"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn("ожидается 'true' или 'false'", str(serializer.errors).lower()) - - def test_checkbox_invalid_type(self): - field = self.make_field("checkbox", is_required=True) - data = {"field_id": field.id, "value_text": 1} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn("ожидается 'true' или 'false'", str(serializer.errors).lower()) - - def test_select_invalid_choice(self): - field = self.make_field("select", is_required=True, options=["арбуз", "ананас"]) - data = {"field_id": field.id, "value_text": "яблоко"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn( - "Недопустимое значение для поля типа 'select'", str(serializer.errors) - ) - - def test_select_required_empty(self): - field = self.make_field("select", is_required=True, options=["арбуз", "ананас"]) - data = {"field_id": field.id, "value_text": ""} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn( - "Значение обязательно для поля типа 'select'", str(serializer.errors) - ) - - def test_radio_invalid_type(self): - field = self.make_field("radio", is_required=True, options=["арбуз", "ананас"]) - data = {"field_id": field.id, "value_text": ["арбуз"]} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn("Not a valid string.", str(serializer.errors)) - - def test_radio_invalid_value(self): - field = self.make_field("radio", is_required=True, options=["арбуз", "ананас"]) - data = {"field_id": field.id, "value_text": "груша"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn( - "Недопустимое значение для поля типа 'radio'", str(serializer.errors) - ) - - def test_file_invalid_type(self): - field = self.make_field("file", is_required=True) - data = {"field_id": field.id, "value_text": 123} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn( - "Ожидается корректная ссылка (URL) на файл.", str(serializer.errors) - ) - - def test_file_empty_required(self): - field = self.make_field("file", is_required=True) - data = {"field_id": field.id, "value_text": ""} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertFalse(serializer.is_valid()) - self.assertIn("Файл обязателен для этого поля.", str(serializer.errors)) - - -class PublishFinishedProgramProjectsTests(TestCase): - def setUp(self): - self.now = timezone.now() - self.user = get_user_model().objects.create_user( - email="user@example.com", - password="pass", - first_name="User", - last_name="Test", - birthday="1990-01-01", - ) - - def create_program(self, **overrides): - defaults = { - "name": "Program", - "tag": "program_tag", - "description": "Program description", - "city": "Moscow", - "image_address": "https://example.com/image.png", - "cover_image_address": "https://example.com/cover.png", - "advertisement_image_address": "https://example.com/advertisement.png", - "presentation_address": "https://example.com/presentation.pdf", - "data_schema": {}, - "draft": False, - "projects_availability": "all_users", - "datetime_registration_ends": self.now - timezone.timedelta(days=5), - "datetime_started": self.now - timezone.timedelta(days=30), - "datetime_finished": self.now - timezone.timedelta(days=1), - } - defaults.update(overrides) - return PartnerProgram.objects.create(**defaults) - - def create_project(self, **overrides): - defaults = { - "leader": self.user, - "draft": False, - "is_public": False, - "name": "Project", - } - defaults.update(overrides) - return Project.objects.create(**defaults) - - def test_publish_updates_projects_from_both_sources(self): - program = self.create_program(publish_projects_after_finish=True) - - link_project = self.create_project(name="Linked Project") - PartnerProgramProject.objects.create( - partner_program=program, - project=link_project, - ) - - profile_project = self.create_project(name="Profile Project") - PartnerProgramUserProfile.objects.create( - user=self.user, - partner_program=program, - project=profile_project, - partner_program_data={}, - ) - - publish_finished_program_projects() - - link_project.refresh_from_db() - profile_project.refresh_from_db() - self.assertTrue(link_project.is_public) - self.assertTrue(profile_project.is_public) - - def test_publish_skips_draft_projects(self): - program = self.create_program(publish_projects_after_finish=True) - draft_project = self.create_project(draft=True, name="Draft Project") - PartnerProgramProject.objects.create( - partner_program=program, - project=draft_project, - ) - - publish_finished_program_projects() - - draft_project.refresh_from_db() - self.assertFalse(draft_project.is_public) - - def test_publish_skips_when_flag_false(self): - program = self.create_program(publish_projects_after_finish=False) - project = self.create_project(name="Private Project") - PartnerProgramProject.objects.create( - partner_program=program, - project=project, - ) - - publish_finished_program_projects() - - project.refresh_from_db() - self.assertFalse(project.is_public) - - def test_publish_after_flag_enabled_post_finish(self): - program = self.create_program(publish_projects_after_finish=False) - project = self.create_project(name="Delayed Project") - PartnerProgramProject.objects.create( - partner_program=program, - project=project, - ) - - publish_finished_program_projects() - project.refresh_from_db() - self.assertFalse(project.is_public) - - program.publish_projects_after_finish = True - program.save(update_fields=["publish_projects_after_finish"]) - - publish_finished_program_projects() - project.refresh_from_db() - self.assertTrue(project.is_public) - - -class PartnerProgramProjectSubmitViewTests(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.view = PartnerProgramProjectSubmitView.as_view() - self.now = timezone.now() - self.user = get_user_model().objects.create_user( - email="leader@example.com", - password="pass", - first_name="Leader", - last_name="User", - birthday="1990-01-01", - ) - - def create_program(self, **overrides): - defaults = { - "name": "Program", - "tag": "program_tag", - "description": "Program description", - "city": "Moscow", - "data_schema": {}, - "draft": False, - "projects_availability": "all_users", - "datetime_registration_ends": self.now + timezone.timedelta(days=10), - "datetime_started": self.now - timezone.timedelta(days=1), - "datetime_finished": self.now + timezone.timedelta(days=30), - "is_competitive": True, - } - defaults.update(overrides) - return PartnerProgram.objects.create(**defaults) - - def create_project_link(self, program): - project = Project.objects.create( - leader=self.user, - draft=False, - is_public=False, - name="Project", - ) - return PartnerProgramProject.objects.create( - partner_program=program, - project=project, - ) - - def test_submit_blocked_after_deadline(self): - program = self.create_program( - datetime_project_submission_ends=self.now - timezone.timedelta(days=1) - ) - link = self.create_project_link(program) - - request = self.factory.post( - f"partner-program-projects/{link.pk}/submit/" - ) - force_authenticate(request, user=self.user) - response = self.view(request, pk=link.pk) - - self.assertEqual(response.status_code, 400) - self.assertEqual( - response.data.get("detail"), - "Срок подачи проектов в программу завершён.", - ) - link.refresh_from_db() - self.assertFalse(link.submitted) - - def test_submit_allowed_before_deadline(self): - program = self.create_program( - datetime_project_submission_ends=self.now + timezone.timedelta(days=1) - ) - link = self.create_project_link(program) - - request = self.factory.post( - f"partner-program-projects/{link.pk}/submit/" - ) - force_authenticate(request, user=self.user) - response = self.view(request, pk=link.pk) - - self.assertEqual(response.status_code, 200) - link.refresh_from_db() - self.assertTrue(link.submitted) - self.assertIsNotNone(link.datetime_submitted) - - -class PartnerProgramFieldValueUpdateSerializerValidTests(TestCase): - def setUp(self): - now = timezone.now() - self.partner_program = PartnerProgram.objects.create( - name="Тестовая программа", - tag="test_tag", - description="Описание тестовой программы", - city="Москва", - image_address="https://example.com/image.png", - cover_image_address="https://example.com/cover.png", - advertisement_image_address="https://example.com/advertisement.png", - presentation_address="https://example.com/presentation.pdf", - data_schema={}, - draft=True, - projects_availability="all_users", - datetime_registration_ends=now + timezone.timedelta(days=30), - datetime_started=now, - datetime_finished=now + timezone.timedelta(days=60), - ) - - def make_field(self, field_type, is_required, options=None): - return PartnerProgramField.objects.create( - partner_program=self.partner_program, - name="test_field", - label="Test Field", - field_type=field_type, - is_required=is_required, - options="|".join(options) if options else "", - ) - - def test_optional_text_field_valid(self): - field = self.make_field("text", is_required=False) - data = {"field_id": field.id, "value_text": "some value"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertTrue(serializer.is_valid()) - - def test_required_text_field_valid(self): - field = self.make_field("text", is_required=True) - data = {"field_id": field.id, "value_text": "not empty"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertTrue(serializer.is_valid()) - - def test_optional_textarea_valid(self): - field = self.make_field("textarea", is_required=False) - data = {"field_id": field.id, "value_text": "optional long text"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertTrue(serializer.is_valid()) - - def test_required_textarea_valid(self): - field = self.make_field("textarea", is_required=True) - data = {"field_id": field.id, "value_text": "required long text"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertTrue(serializer.is_valid()) - - def test_checkbox_true_valid(self): - field = self.make_field("checkbox", is_required=True) - data = {"field_id": field.id, "value_text": "true"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertTrue(serializer.is_valid()) - - def test_checkbox_false_valid(self): - field = self.make_field("checkbox", is_required=False) - data = {"field_id": field.id, "value_text": "false"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertTrue(serializer.is_valid()) - - def test_select_valid(self): - field = self.make_field("select", is_required=True, options=["арбуз", "ананас"]) - data = {"field_id": field.id, "value_text": "ананас"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertTrue(serializer.is_valid()) - - def test_radio_valid(self): - field = self.make_field( - "radio", is_required=True, options=["арбуз", "апельсин"] - ) - data = {"field_id": field.id, "value_text": "апельсин"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertTrue(serializer.is_valid()) - - def test_optional_select_empty_valid(self): - field = self.make_field( - "select", is_required=False, options=["арбуз", "апельсин"] - ) - data = {"field_id": field.id, "value_text": ""} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertTrue(serializer.is_valid()) - - def test_file_valid_url(self): - field = self.make_field("file", is_required=True) - data = {"field_id": field.id, "value_text": "https://example.com/file.pdf"} - serializer = PartnerProgramFieldValueUpdateSerializer(data=data) - self.assertTrue(serializer.is_valid()) - - -class PartnerProgramDetailCoursesTests(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.view = PartnerProgramDetail.as_view() - self.now = timezone.now() - - def create_program(self, **overrides): - defaults = { - "name": "Program with courses", - "tag": "program_with_courses", - "description": "Program description", - "city": "Moscow", - "data_schema": {}, - "draft": False, - "projects_availability": "all_users", - "datetime_registration_ends": self.now + timezone.timedelta(days=10), - "datetime_started": self.now - timezone.timedelta(days=1), - "datetime_finished": self.now + timezone.timedelta(days=30), - } - defaults.update(overrides) - return PartnerProgram.objects.create(**defaults) - - def create_user(self, email: str): - return get_user_model().objects.create_user( - email=email, - password="pass", - first_name="Test", - last_name="User", - birthday="1990-01-01", - ) - - def create_course(self, program: PartnerProgram, **overrides): - defaults = { - "title": "Program course", - "partner_program": program, - "access_type": CourseAccessType.ALL_USERS, - "status": CourseContentStatus.PUBLISHED, - } - defaults.update(overrides) - return Course.objects.create(**defaults) - - def test_detail_includes_related_courses_with_availability_for_member(self): - program = self.create_program() - member = self.create_user("member-program@example.com") - PartnerProgramUserProfile.objects.create( - user=member, - partner_program=program, - project=None, - partner_program_data={}, - ) - all_users_course = self.create_course( - program, - title="Open course", - access_type=CourseAccessType.ALL_USERS, - ) - member_course = self.create_course( - program, - title="Members course", - access_type=CourseAccessType.PROGRAM_MEMBERS, - ) - self.create_course( - program, - title="Draft course", - access_type=CourseAccessType.ALL_USERS, - status=CourseContentStatus.DRAFT, - ) - - request = self.factory.get(f"/programs/{program.id}/") - force_authenticate(request, user=member) - response = self.view(request, pk=program.id) - - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.data["courses"], - [ - { - "id": all_users_course.id, - "title": "Open course", - "is_available": True, - }, - { - "id": member_course.id, - "title": "Members course", - "is_available": True, - }, - ], - ) - - def test_detail_includes_empty_courses_list_when_program_has_no_related_courses(self): - program = self.create_program() - user = self.create_user("plain-user@example.com") - - request = self.factory.get(f"/programs/{program.id}/") - force_authenticate(request, user=user) - response = self.view(request, pk=program.id) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["courses"], []) - - def test_detail_marks_program_only_courses_as_unavailable_for_non_member(self): - program = self.create_program() - outsider = self.create_user("outsider-program@example.com") - open_course = self.create_course( - program, - title="Open course", - access_type=CourseAccessType.ALL_USERS, - ) - member_course = self.create_course( - program, - title="Members course", - access_type=CourseAccessType.PROGRAM_MEMBERS, - ) - - request = self.factory.get(f"/programs/{program.id}/") - force_authenticate(request, user=outsider) - response = self.view(request, pk=program.id) - - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.data["courses"], - [ - { - "id": open_course.id, - "title": "Open course", - "is_available": True, - }, - { - "id": member_course.id, - "title": "Members course", - "is_available": False, - }, - ], - ) diff --git a/partner_programs/tests/__init__.py b/partner_programs/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/partner_programs/tests/helpers.py b/partner_programs/tests/helpers.py new file mode 100644 index 00000000..a1863790 --- /dev/null +++ b/partner_programs/tests/helpers.py @@ -0,0 +1,141 @@ +from itertools import count + +from django.contrib.auth import get_user_model +from django.utils import timezone + +from courses.models import Course, CourseAccessType, CourseContentStatus +from partner_programs.models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from projects.models import Project + +User = get_user_model() + +_counter = count(1) + + +def create_user(*, prefix: str = "program-user", **overrides): + index = next(_counter) + defaults = { + "email": f"{prefix}-{index}@example.com", + "password": "pass", + "first_name": "Program", + "last_name": "User", + "birthday": "1990-01-01", + } + defaults.update(overrides) + return User.objects.create_user(**defaults) + + +def create_partner_program(**overrides) -> PartnerProgram: + index = next(_counter) + now = timezone.now() + defaults = { + "name": f"Program {index}", + "tag": f"program-{index}", + "description": "Program description", + "city": "Moscow", + "data_schema": {}, + "draft": False, + "projects_availability": "all_users", + "datetime_registration_ends": now + timezone.timedelta(days=30), + "datetime_started": now - timezone.timedelta(days=1), + "datetime_finished": now + timezone.timedelta(days=60), + } + defaults.update(overrides) + return PartnerProgram.objects.create(**defaults) + + +def create_program_field( + program: PartnerProgram, + *, + field_type: str = "text", + name: str | None = None, + label: str | None = None, + is_required: bool = False, + options: list[str] | None = None, + show_filter: bool = False, +) -> PartnerProgramField: + index = next(_counter) + return PartnerProgramField.objects.create( + partner_program=program, + name=name or f"field_{index}", + label=label or f"Field {index}", + field_type=field_type, + is_required=is_required, + options="|".join(options) if options else "", + show_filter=show_filter, + ) + + +def create_project(*, leader=None, **overrides) -> Project: + index = next(_counter) + defaults = { + "leader": leader or create_user(prefix="project-leader"), + "name": f"Project {index}", + "description": "Project description", + "draft": False, + "is_public": False, + } + defaults.update(overrides) + return Project.objects.create(**defaults) + + +def create_program_member( + program: PartnerProgram, + *, + user=None, + project: Project | None = None, + data: dict | None = None, +) -> PartnerProgramUserProfile: + return PartnerProgramUserProfile.objects.create( + user=user or create_user(prefix="program-member"), + partner_program=program, + project=project, + partner_program_data=data or {}, + ) + + +def create_program_project( + program: PartnerProgram, + *, + project: Project | None = None, + submitted: bool = False, +) -> PartnerProgramProject: + return PartnerProgramProject.objects.create( + partner_program=program, + project=project or create_project(), + submitted=submitted, + datetime_submitted=timezone.now() if submitted else None, + ) + + +def create_course(program: PartnerProgram, **overrides) -> Course: + defaults = { + "title": "Program course", + "partner_program": program, + "access_type": CourseAccessType.ALL_USERS, + "status": CourseContentStatus.PUBLISHED, + } + defaults.update(overrides) + return Course.objects.create(**defaults) + + +def project_apply_payload( + *, + project: dict | None = None, + program_field_values: list[dict] | None = None, +) -> dict: + project_data = { + "name": "Submitted project", + "description": "Submitted project description", + } + if project: + project_data.update(project) + return { + "project": project_data, + "program_field_values": program_field_values or [], + } diff --git a/partner_programs/tests/test_field_values.py b/partner_programs/tests/test_field_values.py new file mode 100644 index 00000000..24798197 --- /dev/null +++ b/partner_programs/tests/test_field_values.py @@ -0,0 +1,216 @@ +from django.test import TestCase + +from partner_programs.serializers import PartnerProgramFieldValueUpdateSerializer +from partner_programs.tests.helpers import create_partner_program, create_program_field + + +class PartnerProgramFieldValueUpdateSerializerInvalidTests(TestCase): + def setUp(self): + self.partner_program = create_partner_program(draft=True) + + def make_field(self, field_type, is_required, options=None): + return create_program_field( + self.partner_program, + field_type=field_type, + is_required=is_required, + options=options, + ) + + def test_required_text_field_empty(self): + field = self.make_field("text", is_required=True) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": ""} + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Поле должно содержать текстовое значение.", str(serializer.errors) + ) + + def test_required_textarea_field_null(self): + field = self.make_field("textarea", is_required=True) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": None} + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Поле должно содержать текстовое значение.", str(serializer.errors) + ) + + def test_checkbox_invalid_string(self): + field = self.make_field("checkbox", is_required=True) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "maybe"} + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("ожидается 'true' или 'false'", str(serializer.errors).lower()) + + def test_checkbox_invalid_type(self): + field = self.make_field("checkbox", is_required=True) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": 1} + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("ожидается 'true' или 'false'", str(serializer.errors).lower()) + + def test_select_invalid_choice(self): + field = self.make_field("select", is_required=True, options=["арбуз", "ананас"]) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "яблоко"} + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Недопустимое значение для поля типа 'select'", str(serializer.errors) + ) + + def test_select_required_empty(self): + field = self.make_field("select", is_required=True, options=["арбуз", "ананас"]) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": ""} + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Значение обязательно для поля типа 'select'", str(serializer.errors) + ) + + def test_radio_invalid_type(self): + field = self.make_field("radio", is_required=True, options=["арбуз", "ананас"]) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": ["арбуз"]} + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("Not a valid string.", str(serializer.errors)) + + def test_radio_invalid_value(self): + field = self.make_field("radio", is_required=True, options=["арбуз", "ананас"]) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "груша"} + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Недопустимое значение для поля типа 'radio'", str(serializer.errors) + ) + + def test_file_invalid_type(self): + field = self.make_field("file", is_required=True) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": 123} + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn( + "Ожидается корректная ссылка (URL) на файл.", str(serializer.errors) + ) + + def test_file_empty_required(self): + field = self.make_field("file", is_required=True) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": ""} + ) + + self.assertFalse(serializer.is_valid()) + self.assertIn("Файл обязателен для этого поля.", str(serializer.errors)) + + +class PartnerProgramFieldValueUpdateSerializerValidTests(TestCase): + def setUp(self): + self.partner_program = create_partner_program(draft=True) + + def make_field(self, field_type, is_required, options=None): + return create_program_field( + self.partner_program, + field_type=field_type, + is_required=is_required, + options=options, + ) + + def test_optional_text_field_valid(self): + field = self.make_field("text", is_required=False) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "some value"} + ) + + self.assertTrue(serializer.is_valid()) + + def test_required_text_field_valid(self): + field = self.make_field("text", is_required=True) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "not empty"} + ) + + self.assertTrue(serializer.is_valid()) + + def test_optional_textarea_valid(self): + field = self.make_field("textarea", is_required=False) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "optional long text"} + ) + + self.assertTrue(serializer.is_valid()) + + def test_required_textarea_valid(self): + field = self.make_field("textarea", is_required=True) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "required long text"} + ) + + self.assertTrue(serializer.is_valid()) + + def test_checkbox_true_valid(self): + field = self.make_field("checkbox", is_required=True) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "true"} + ) + + self.assertTrue(serializer.is_valid()) + + def test_checkbox_false_valid(self): + field = self.make_field("checkbox", is_required=False) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "false"} + ) + + self.assertTrue(serializer.is_valid()) + + def test_select_valid(self): + field = self.make_field("select", is_required=True, options=["арбуз", "ананас"]) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "ананас"} + ) + + self.assertTrue(serializer.is_valid()) + + def test_radio_valid(self): + field = self.make_field( + "radio", is_required=True, options=["арбуз", "апельсин"] + ) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "апельсин"} + ) + + self.assertTrue(serializer.is_valid()) + + def test_optional_select_empty_valid(self): + field = self.make_field( + "select", is_required=False, options=["арбуз", "апельсин"] + ) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": ""} + ) + + self.assertTrue(serializer.is_valid()) + + def test_file_valid_url(self): + field = self.make_field("file", is_required=True) + serializer = PartnerProgramFieldValueUpdateSerializer( + data={"field_id": field.id, "value_text": "https://example.com/file.pdf"} + ) + + self.assertTrue(serializer.is_valid()) diff --git a/partner_programs/tests/test_program_api.py b/partner_programs/tests/test_program_api.py new file mode 100644 index 00000000..e9989b3b --- /dev/null +++ b/partner_programs/tests/test_program_api.py @@ -0,0 +1,139 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramUserProfile +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_user, +) + + +class PartnerProgramListAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_list_returns_only_published_programs(self): + published = create_partner_program(name="Published program", draft=False) + draft = create_partner_program(name="Draft program", draft=True) + + response = self.client.get("/programs/") + + self.assertEqual(response.status_code, 200) + program_ids = {item["id"] for item in response.data["results"]} + self.assertIn(published.id, program_ids) + self.assertNotIn(draft.id, program_ids) + + def test_participating_filter_returns_active_programs_for_current_user(self): + user = create_user(prefix="program-list-member") + active_program = create_partner_program( + name="Active member program", + datetime_finished=timezone.now() + timezone.timedelta(days=10), + ) + finished_program = create_partner_program( + name="Finished member program", + datetime_finished=timezone.now() - timezone.timedelta(days=1), + ) + other_program = create_partner_program(name="Other program") + create_program_member(active_program, user=user) + create_program_member(finished_program, user=user) + self.client.force_authenticate(user) + + response = self.client.get("/programs/?participating=1") + + self.assertEqual(response.status_code, 200) + program_ids = {item["id"] for item in response.data["results"]} + self.assertEqual(program_ids, {active_program.id}) + self.assertNotIn(other_program.id, program_ids) + self.assertTrue(response.data["results"][0]["is_user_member"]) + + +class PartnerProgramRegisterAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="program-register-user") + self.program = create_partner_program() + + @patch("partner_programs.views.send_email.delay") + def test_authenticated_user_can_register_to_program(self, send_email_delay): + self.client.force_authenticate(self.user) + + response = self.client.post( + f"/programs/{self.program.id}/register/", + {"telegram": "@program_user"}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + profile = PartnerProgramUserProfile.objects.get( + user=self.user, + partner_program=self.program, + ) + self.assertEqual(profile.partner_program_data, {"telegram": "@program_user"}) + send_email_delay.assert_called_once() + + @patch("partner_programs.views.send_email.delay") + def test_authenticated_user_cannot_register_twice(self, send_email_delay): + create_program_member(self.program, user=self.user) + self.client.force_authenticate(self.user) + + response = self.client.post( + f"/programs/{self.program.id}/register/", + {"telegram": "@program_user"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["detail"], + "User already registered to this program.", + ) + send_email_delay.assert_not_called() + + @patch("partner_programs.views.send_email.delay") + def test_registration_is_blocked_after_deadline(self, send_email_delay): + self.program.datetime_registration_ends = timezone.now() - timezone.timedelta( + days=1 + ) + self.program.save(update_fields=["datetime_registration_ends"]) + self.client.force_authenticate(self.user) + + response = self.client.post( + f"/programs/{self.program.id}/register/", + {"telegram": "@program_user"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["detail"], "Registration period has ended.") + send_email_delay.assert_not_called() + + @patch("partner_programs.views.send_email.delay") + def test_external_registration_creates_user_and_program_profile( + self, + send_email_delay, + ): + response = self.client.post( + f"/programs/{self.program.id}/register_new/", + { + "email": "external-program-user@example.com", + "password": "pass", + "first_name": "External", + "last_name": "User", + "birthday": "01-01-1990", + "telegram": "@external", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + profile = PartnerProgramUserProfile.objects.select_related("user").get( + partner_program=self.program, + user__email="external-program-user@example.com", + ) + self.assertTrue(profile.user.is_active) + self.assertEqual(profile.partner_program_data["telegram"], "@external") + send_email_delay.assert_called_once() diff --git a/partner_programs/tests/test_program_detail.py b/partner_programs/tests/test_program_detail.py new file mode 100644 index 00000000..855b1103 --- /dev/null +++ b/partner_programs/tests/test_program_detail.py @@ -0,0 +1,102 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from courses.models import CourseAccessType, CourseContentStatus +from partner_programs.tests.helpers import ( + create_course, + create_partner_program, + create_program_member, + create_user, +) + + +class PartnerProgramDetailCoursesTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_detail_includes_related_courses_with_availability_for_member(self): + program = create_partner_program(name="Program with courses") + member = create_user(prefix="member-program") + create_program_member(program, user=member) + all_users_course = create_course( + program, + title="Open course", + access_type=CourseAccessType.ALL_USERS, + ) + member_course = create_course( + program, + title="Members course", + access_type=CourseAccessType.PROGRAM_MEMBERS, + ) + create_course( + program, + title="Draft course", + access_type=CourseAccessType.ALL_USERS, + status=CourseContentStatus.DRAFT, + ) + self.client.force_authenticate(member) + + response = self.client.get(f"/programs/{program.id}/") + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["is_user_member"]) + self.assertEqual( + response.data["courses"], + [ + { + "id": all_users_course.id, + "title": "Open course", + "is_available": True, + }, + { + "id": member_course.id, + "title": "Members course", + "is_available": True, + }, + ], + ) + + def test_detail_includes_empty_courses_list_when_program_has_no_related_courses(self): + program = create_partner_program() + user = create_user(prefix="plain-program-user") + self.client.force_authenticate(user) + + response = self.client.get(f"/programs/{program.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["courses"], []) + + def test_detail_marks_program_only_courses_as_unavailable_for_non_member(self): + program = create_partner_program() + outsider = create_user(prefix="outsider-program") + open_course = create_course( + program, + title="Open course", + access_type=CourseAccessType.ALL_USERS, + ) + member_course = create_course( + program, + title="Members course", + access_type=CourseAccessType.PROGRAM_MEMBERS, + ) + self.client.force_authenticate(outsider) + + response = self.client.get(f"/programs/{program.id}/") + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["is_user_member"]) + self.assertEqual( + response.data["courses"], + [ + { + "id": open_course.id, + "title": "Open course", + "is_available": True, + }, + { + "id": member_course.id, + "title": "Members course", + "is_available": False, + }, + ], + ) diff --git a/partner_programs/tests/test_program_filters.py b/partner_programs/tests/test_program_filters.py new file mode 100644 index 00000000..2c5d8452 --- /dev/null +++ b/partner_programs/tests/test_program_filters.py @@ -0,0 +1,130 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramFieldValue +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_project, + create_project, + create_user, +) + + +class PartnerProgramProjectFilterAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="program-filter-manager") + self.program = create_partner_program() + self.program.managers.add(self.manager) + + def test_manager_can_get_filterable_program_fields(self): + filterable = create_program_field( + self.program, + name="track", + label="Track", + field_type="select", + options=["ai", "edu"], + show_filter=True, + ) + hidden = create_program_field( + self.program, + name="internal", + label="Internal", + show_filter=False, + ) + self.client.force_authenticate(self.manager) + + response = self.client.get(f"/programs/{self.program.id}/filters/") + + self.assertEqual(response.status_code, 200) + field_ids = {item["id"] for item in response.data} + self.assertIn(filterable.id, field_ids) + self.assertNotIn(hidden.id, field_ids) + + def test_non_manager_cannot_get_filterable_program_fields(self): + outsider = create_user(prefix="program-filter-outsider") + self.client.force_authenticate(outsider) + + response = self.client.get(f"/programs/{self.program.id}/filters/") + + self.assertEqual(response.status_code, 403) + + def test_manager_can_filter_program_projects_by_field_value(self): + field = create_program_field( + self.program, + name="track", + field_type="select", + options=["ai", "edu"], + show_filter=True, + ) + matching_project = create_project(name="AI project") + other_project = create_project(name="Education project") + matching_link = create_program_project(self.program, project=matching_project) + other_link = create_program_project(self.program, project=other_project) + PartnerProgramFieldValue.objects.create( + program_project=matching_link, + field=field, + value_text="ai", + ) + PartnerProgramFieldValue.objects.create( + program_project=other_link, + field=field, + value_text="edu", + ) + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.id}/projects/filter/", + {"filters": {"track": ["ai"]}}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + project_ids = {item["id"] for item in response.data["results"]} + self.assertEqual(project_ids, {matching_project.id}) + + def test_filter_rejects_field_that_is_not_filterable(self): + create_program_field( + self.program, + name="internal", + field_type="select", + options=["yes", "no"], + show_filter=False, + ) + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.id}/projects/filter/", + {"filters": {"internal": ["yes"]}}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + +class PartnerProgramProjectsAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="program-projects-manager") + self.program = create_partner_program() + self.program.managers.add(self.manager) + + def test_manager_can_get_program_projects(self): + project = create_project(name="Program project") + create_program_project(self.program, project=project) + self.client.force_authenticate(self.manager) + + response = self.client.get(f"/programs/{self.program.id}/projects/") + + self.assertEqual(response.status_code, 200) + project_ids = {item["id"] for item in response.data["results"]} + self.assertEqual(project_ids, {project.id}) + + def test_non_manager_cannot_get_program_projects(self): + outsider = create_user(prefix="program-projects-outsider") + self.client.force_authenticate(outsider) + + response = self.client.get(f"/programs/{self.program.id}/projects/") + + self.assertEqual(response.status_code, 403) diff --git a/partner_programs/tests/test_program_project_submit.py b/partner_programs/tests/test_program_project_submit.py new file mode 100644 index 00000000..3208c4f3 --- /dev/null +++ b/partner_programs/tests/test_program_project_submit.py @@ -0,0 +1,78 @@ +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_project, + create_project, + create_user, +) + + +class PartnerProgramProjectSubmitViewTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="submit-program-leader") + + def create_program(self, **overrides): + defaults = { + "is_competitive": True, + "datetime_project_submission_ends": timezone.now() + + timezone.timedelta(days=1), + } + defaults.update(overrides) + return create_partner_program(**defaults) + + def create_project_link(self, program): + project = create_project(leader=self.user, draft=False, is_public=False) + return create_program_project(program, project=project) + + def test_submit_blocked_after_deadline(self): + program = self.create_program( + datetime_project_submission_ends=timezone.now() - timezone.timedelta(days=1) + ) + link = self.create_project_link(program) + self.client.force_authenticate(self.user) + + response = self.client.post(f"/programs/partner-program-projects/{link.pk}/submit/") + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data.get("detail"), + "Срок подачи проектов в программу завершён.", + ) + link.refresh_from_db() + self.assertFalse(link.submitted) + + def test_submit_allowed_before_deadline(self): + program = self.create_program() + link = self.create_project_link(program) + self.client.force_authenticate(self.user) + + response = self.client.post(f"/programs/partner-program-projects/{link.pk}/submit/") + + self.assertEqual(response.status_code, 200) + link.refresh_from_db() + self.assertTrue(link.submitted) + self.assertIsNotNone(link.datetime_submitted) + + def test_submit_rejects_non_competitive_program(self): + program = self.create_program(is_competitive=False) + link = self.create_project_link(program) + self.client.force_authenticate(self.user) + + response = self.client.post(f"/programs/partner-program-projects/{link.pk}/submit/") + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["detail"], "Программа не является конкурсной.") + + def test_submit_rejects_non_leader(self): + program = self.create_program() + link = self.create_project_link(program) + outsider = create_user(prefix="submit-program-outsider") + self.client.force_authenticate(outsider) + + response = self.client.post(f"/programs/partner-program-projects/{link.pk}/submit/") + + self.assertEqual(response.status_code, 403) diff --git a/partner_programs/tests/test_project_apply.py b/partner_programs/tests/test_project_apply.py new file mode 100644 index 00000000..f4f675ca --- /dev/null +++ b/partner_programs/tests/test_project_apply.py @@ -0,0 +1,168 @@ +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramFieldValue, PartnerProgramProject +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_member, + create_user, + project_apply_payload, +) + + +class PartnerProgramProjectApplyAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="program-apply-user") + self.program = create_partner_program() + + def test_member_can_get_project_apply_schema(self): + create_program_member(self.program, user=self.user) + field = create_program_field( + self.program, + name="track", + label="Track", + field_type="select", + options=["ai", "edu"], + is_required=True, + ) + self.client.force_authenticate(self.user) + + response = self.client.get(f"/programs/{self.program.id}/projects/apply/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["program_id"], self.program.id) + self.assertTrue(response.data["can_submit"]) + self.assertEqual(response.data["program_fields"][0]["id"], field.id) + + def test_non_member_cannot_apply_project(self): + self.client.force_authenticate(self.user) + + response = self.client.post( + f"/programs/{self.program.id}/projects/apply/", + project_apply_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 403) + + def test_member_can_apply_project_with_program_field_values(self): + profile = create_program_member(self.program, user=self.user) + field = create_program_field( + self.program, + name="track", + label="Track", + field_type="select", + options=["ai", "edu"], + is_required=True, + ) + self.client.force_authenticate(self.user) + + response = self.client.post( + f"/programs/{self.program.id}/projects/apply/", + project_apply_payload( + project={"name": "Program project"}, + program_field_values=[ + {"field_id": field.id, "value_text": "ai"}, + ], + ), + format="json", + ) + + self.assertEqual(response.status_code, 201) + program_link = PartnerProgramProject.objects.select_related("project").get( + id=response.data["program_link_id"] + ) + self.assertEqual(program_link.partner_program, self.program) + self.assertEqual(program_link.project.leader, self.user) + self.assertTrue(program_link.project.draft) + self.assertFalse(program_link.project.is_public) + self.assertEqual(program_link.project.name, "Program project") + self.assertTrue( + PartnerProgramFieldValue.objects.filter( + program_project=program_link, + field=field, + value_text="ai", + ).exists() + ) + profile.refresh_from_db() + self.assertEqual(profile.project, program_link.project) + + def test_apply_project_rejects_duplicate_field_values(self): + create_program_member(self.program, user=self.user) + field = create_program_field(self.program, name="track") + self.client.force_authenticate(self.user) + + response = self.client.post( + f"/programs/{self.program.id}/projects/apply/", + project_apply_payload( + program_field_values=[ + {"field_id": field.id, "value_text": "first"}, + {"field_id": field.id, "value_text": "second"}, + ], + ), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(PartnerProgramProject.objects.exists()) + + def test_apply_project_rejects_missing_required_program_fields(self): + create_program_member(self.program, user=self.user) + create_program_field( + self.program, + name="track", + label="Track", + is_required=True, + ) + self.client.force_authenticate(self.user) + + response = self.client.post( + f"/programs/{self.program.id}/projects/apply/", + project_apply_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(PartnerProgramProject.objects.exists()) + + def test_apply_project_rejects_field_from_another_program(self): + create_program_member(self.program, user=self.user) + other_program = create_partner_program(name="Other program") + other_field = create_program_field(other_program, name="foreign_field") + self.client.force_authenticate(self.user) + + response = self.client.post( + f"/programs/{self.program.id}/projects/apply/", + project_apply_payload( + program_field_values=[ + {"field_id": other_field.id, "value_text": "foreign"}, + ], + ), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(PartnerProgramProject.objects.exists()) + + def test_apply_project_is_blocked_after_submission_deadline(self): + self.program.datetime_project_submission_ends = ( + timezone.now() - timezone.timedelta(days=1) + ) + self.program.save(update_fields=["datetime_project_submission_ends"]) + create_program_member(self.program, user=self.user) + self.client.force_authenticate(self.user) + + response = self.client.post( + f"/programs/{self.program.id}/projects/apply/", + project_apply_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data[0], + "Срок подачи проектов в программу завершён.", + ) diff --git a/partner_programs/tests/test_project_field_values_api.py b/partner_programs/tests/test_project_field_values_api.py new file mode 100644 index 00000000..640f5ccf --- /dev/null +++ b/partner_programs/tests/test_project_field_values_api.py @@ -0,0 +1,80 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramFieldValue +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_project, + create_project, + create_user, +) + + +class PartnerProgramFieldValueBulkUpdateAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.leader = create_user(prefix="program-fields-leader") + self.program = create_partner_program() + self.project = create_project(leader=self.leader) + self.program_link = create_program_project(self.program, project=self.project) + self.field = create_program_field(self.program, name="track") + + def test_project_leader_can_update_program_field_values(self): + self.client.force_authenticate(self.leader) + + response = self.client.put( + f"/projects/{self.project.id}/program-fields/", + [{"field_id": self.field.id, "value_text": "ai"}], + format="json", + ) + + self.assertEqual(response.status_code, 200) + value = PartnerProgramFieldValue.objects.get( + program_project=self.program_link, + field=self.field, + ) + self.assertEqual(value.value_text, "ai") + + def test_non_leader_cannot_update_program_field_values(self): + outsider = create_user(prefix="program-fields-outsider") + self.client.force_authenticate(outsider) + + response = self.client.put( + f"/projects/{self.project.id}/program-fields/", + [{"field_id": self.field.id, "value_text": "ai"}], + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse(PartnerProgramFieldValue.objects.exists()) + + def test_submitted_competitive_project_fields_cannot_be_changed(self): + self.program.is_competitive = True + self.program.save(update_fields=["is_competitive"]) + self.program_link.submitted = True + self.program_link.save(update_fields=["submitted"]) + self.client.force_authenticate(self.leader) + + response = self.client.put( + f"/projects/{self.project.id}/program-fields/", + [{"field_id": self.field.id, "value_text": "ai"}], + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(PartnerProgramFieldValue.objects.exists()) + + def test_field_from_another_program_is_rejected(self): + other_program = create_partner_program(name="Other field program") + other_field = create_program_field(other_program, name="foreign") + self.client.force_authenticate(self.leader) + + response = self.client.put( + f"/projects/{self.project.id}/program-fields/", + [{"field_id": other_field.id, "value_text": "foreign"}], + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(PartnerProgramFieldValue.objects.exists()) diff --git a/partner_programs/tests/test_services.py b/partner_programs/tests/test_services.py new file mode 100644 index 00000000..cf2c60ab --- /dev/null +++ b/partner_programs/tests/test_services.py @@ -0,0 +1,98 @@ +from django.test import TestCase +from django.utils import timezone + +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.services import publish_finished_program_projects +from partner_programs.tests.helpers import ( + create_partner_program, + create_project, + create_user, +) + + +class PublishFinishedProgramProjectsTests(TestCase): + def setUp(self): + self.now = timezone.now() + self.user = create_user(prefix="publish-program-user") + + def create_program(self, **overrides): + defaults = { + "publish_projects_after_finish": True, + "datetime_registration_ends": self.now - timezone.timedelta(days=5), + "datetime_started": self.now - timezone.timedelta(days=30), + "datetime_finished": self.now - timezone.timedelta(days=1), + } + defaults.update(overrides) + return create_partner_program(**defaults) + + def create_project(self, **overrides): + return create_project(leader=self.user, is_public=False, **overrides) + + def test_publish_updates_projects_from_both_sources(self): + program = self.create_program() + + link_project = self.create_project(name="Linked Project") + PartnerProgramProject.objects.create( + partner_program=program, + project=link_project, + ) + + profile_project = self.create_project(name="Profile Project") + PartnerProgramUserProfile.objects.create( + user=self.user, + partner_program=program, + project=profile_project, + partner_program_data={}, + ) + + publish_finished_program_projects() + + link_project.refresh_from_db() + profile_project.refresh_from_db() + self.assertTrue(link_project.is_public) + self.assertTrue(profile_project.is_public) + + def test_publish_skips_draft_projects(self): + program = self.create_program() + draft_project = self.create_project(draft=True, name="Draft Project") + PartnerProgramProject.objects.create( + partner_program=program, + project=draft_project, + ) + + publish_finished_program_projects() + + draft_project.refresh_from_db() + self.assertFalse(draft_project.is_public) + + def test_publish_skips_when_flag_false(self): + program = self.create_program(publish_projects_after_finish=False) + project = self.create_project(name="Private Project") + PartnerProgramProject.objects.create( + partner_program=program, + project=project, + ) + + publish_finished_program_projects() + + project.refresh_from_db() + self.assertFalse(project.is_public) + + def test_publish_after_flag_enabled_post_finish(self): + program = self.create_program(publish_projects_after_finish=False) + project = self.create_project(name="Delayed Project") + PartnerProgramProject.objects.create( + partner_program=program, + project=project, + ) + + publish_finished_program_projects() + project.refresh_from_db() + self.assertFalse(project.is_public) + + program.publish_projects_after_finish = True + program.save(update_fields=["publish_projects_after_finish"]) + + publish_finished_program_projects() + project.refresh_from_db() + self.assertTrue(project.is_public) From 01431fa7d6c56af06a2996886d3bff75e94ed38d Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 27 May 2026 11:00:59 +0500 Subject: [PATCH 17/32] =?UTF-8?q?=D0=9B=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20=D0=B8=D0=B7?= =?UTF-8?q?=20views=20=D0=B2=20services?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/partner-programs.md | 10 +- partner_programs/services.py | 415 +++++++++++++++++- partner_programs/tests/test_program_api.py | 113 ++++- .../tests/test_program_filters.py | 115 +++++ partner_programs/tests/test_project_apply.py | 44 ++ partner_programs/views.py | 360 +++------------ 6 files changed, 748 insertions(+), 309 deletions(-) diff --git a/docs/modules/partner-programs.md b/docs/modules/partner-programs.md index a1bdd35f..7a4708d4 100644 --- a/docs/modules/partner-programs.md +++ b/docs/modules/partner-programs.md @@ -36,8 +36,9 @@ фильтров и экспортов. - `partner_programs/serializers/` - request/response serializers и validation дополнительных полей. -- `partner_programs/services.py` - сервисы публикации проектов и подготовки - данных для Excel-выгрузок. +- `partner_programs/services.py` - сервисы регистрации в программе, подачи + проектов, фильтрации проектов, публикации проектов и подготовки + Excel-выгрузок. - `partner_programs/selectors.py` - выборки участников для аналитики и напоминаний. - `partner_programs/permissions.py` - проверки менеджера программы, админа и @@ -191,7 +192,8 @@ - регистрацию нового пользователя через внешнюю форму; - запрет повторной регистрации и регистрации после дедлайна; - detail программы с курсами для участника и не-участника; -- подачу проекта участником программы; +- подачу проекта участником программы или менеджером программы; +- запрет повторной подачи проекта тем же лидером; - запрет подачи проекта не-участником, после дедлайна, с дублями полей, незаполненными обязательными полями и полями другой программы; - обновление дополнительных полей проекта лидером; @@ -201,4 +203,6 @@ - список фильтров программы; - фильтрацию проектов программы по дополнительным полям; - список проектов программы для менеджера; +- Excel-выгрузку проектов программы, включая режим `only_submitted`; +- запрет выгрузки проектов пользователем без прав менеджера; - публикацию проектов после завершения программы. diff --git a/partner_programs/services.py b/partner_programs/services.py index d758de6b..f0023c1d 100644 --- a/partner_programs/services.py +++ b/partner_programs/services.py @@ -1,9 +1,17 @@ import logging +import io from collections import OrderedDict +from dataclasses import dataclass -from django.db.models import Prefetch +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.db.models import Exists, OuterRef, Prefetch from django.utils import timezone +from openpyxl import Workbook +from rest_framework.exceptions import PermissionDenied, ValidationError +from core.utils import XlsxFileToExport, sanitize_excel_value +from partner_programs.helpers import date_to_iso from partner_programs.models import ( PartnerProgram, PartnerProgramField, @@ -12,9 +20,341 @@ PartnerProgramUserProfile, ) from project_rates.models import Criteria, ProjectScore -from projects.models import Project +from projects.models import Collaborator, Project +from vacancy.mapping import MessageTypeEnum, UserProgramRegisterParams +from vacancy.tasks import send_email logger = logging.getLogger() +User = get_user_model() + +EXTERNAL_REGISTRATION_USER_FIELDS = ( + "first_name", + "last_name", + "patronymic", + "city", +) + + +class ProgramRegistrationError(Exception): + def __init__(self, detail: str): + self.detail = detail + super().__init__(detail) + + +class ProgramProjectAlreadyApplied(Exception): + def __init__(self, program_link: PartnerProgramProject): + self.program_link = program_link + super().__init__("Проект уже подан в эту программу.") + + +class ProgramProjectFilterError(Exception): + def __init__(self, detail: dict): + self.detail = detail + super().__init__(str(detail)) + + +@dataclass(frozen=True) +class ProgramProjectApplicationResult: + project: Project + program_link: PartnerProgramProject + + +@dataclass(frozen=True) +class ProgramExportFile: + binary_data: bytes + base_name: str + + +def _send_program_registration_email(user, program: PartnerProgram) -> None: + send_email.delay( + UserProgramRegisterParams( + message_type=MessageTypeEnum.REGISTERED_PROGRAM_USER.value, + user_id=user.id, + program_name=program.name, + program_id=program.id, + schema_id=2, + ) + ) + + +def register_user_to_program( + *, + program: PartnerProgram, + user: User, + data, +) -> PartnerProgramUserProfile: + if program.datetime_registration_ends < timezone.now(): + raise ProgramRegistrationError("Registration period has ended.") + + try: + user_profile = PartnerProgramUserProfile.objects.create( + partner_program_data=data, + user=user, + partner_program=program, + ) + except IntegrityError: + raise ProgramRegistrationError("User already registered to this program.") + + _send_program_registration_email(user, program) + return user_profile + + +def create_user_and_register_to_program( + *, + program: PartnerProgram, + data, +) -> PartnerProgramUserProfile: + email = data.get("email") if data.get("email") else data.get("email_") + if not email: + raise ProgramRegistrationError("You need to pass an email address.") + + password = data.get("password") + if not password: + raise ProgramRegistrationError("You need to pass a password.") + + user, created = User.objects.get_or_create( + email=email, + defaults={ + "birthday": date_to_iso(data.get("birthday", "01-01-1900")), + "is_active": True, # bypass email verification for external forms + "onboarding_stage": None, # bypass onboarding for external forms + "verification_date": timezone.now(), # bypass manual verification + **{ + field_name: data.get(field_name, "") + for field_name in EXTERNAL_REGISTRATION_USER_FIELDS + }, + }, + ) + if created: + user.set_password(password) + user.save() + + user_profile_program_data = { + k: v + for k, v in data.items() + if k not in EXTERNAL_REGISTRATION_USER_FIELDS and k != "password" + } + try: + user_profile = PartnerProgramUserProfile.objects.create( + partner_program_data=user_profile_program_data, + user=user, + partner_program=program, + ) + except IntegrityError: + raise ProgramRegistrationError( + "User has already registered in this program." + ) + + _send_program_registration_email(user, program) + return user_profile + + +def require_can_apply_project_to_program( + *, + program: PartnerProgram, + user: User, +) -> None: + if not program.is_project_submission_open(): + raise ValidationError("Срок подачи проектов в программу завершён.") + + if program.is_manager(user): + return + + if not PartnerProgramUserProfile.objects.filter( + user=user, + partner_program=program, + ).exists(): + raise PermissionDenied("Подача проекта доступна только участникам программы.") + + +def _validate_unique_program_fields(values_data: list[dict]) -> None: + seen_field_ids: set[int] = set() + duplicate_ids: set[int] = set() + for item in values_data: + field_id = item["field"].id + if field_id in seen_field_ids: + duplicate_ids.add(field_id) + seen_field_ids.add(field_id) + if duplicate_ids: + raise ValidationError( + {"program_field_values": f"Есть повторяющиеся field_id: {sorted(duplicate_ids)}"} + ) + + +def _validate_required_program_fields( + *, + program: PartnerProgram, + values_data: list[dict], +) -> None: + required_fields = list( + program.fields.filter(is_required=True).values("id", "label") + ) + provided_field_ids = {item["field"].id for item in values_data} + missing_required = [ + field["label"] + for field in required_fields + if field["id"] not in provided_field_ids + ] + if missing_required: + raise ValidationError( + {"program_field_values": f"Не заполнены обязательные поля: {missing_required}"} + ) + + +def _validate_program_field_ownership( + *, + program: PartnerProgram, + values_data: list[dict], +) -> None: + for item in values_data: + field = item["field"] + if field.partner_program_id != program.id: + raise ValidationError( + { + "program_field_values": f"Поле id={field.id} не относится к этой программе." + } + ) + + +def apply_project_to_program( + *, + program: PartnerProgram, + user: User, + data, + serializer_class, +) -> ProgramProjectApplicationResult: + require_can_apply_project_to_program(program=program, user=user) + + existing_link = ( + PartnerProgramProject.objects.select_related("project") + .filter(partner_program=program, project__leader=user) + .first() + ) + if existing_link: + raise ProgramProjectAlreadyApplied(existing_link) + + serializer = serializer_class(data=data) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + + project_data = validated_data["project"] + values_data = validated_data.get("program_field_values") or [] + + _validate_unique_program_fields(values_data) + _validate_required_program_fields(program=program, values_data=values_data) + _validate_program_field_ownership(program=program, values_data=values_data) + + with transaction.atomic(): + project = Project.objects.create( + leader=user, + draft=True, + is_public=False, + **project_data, + ) + program_link = PartnerProgramProject.objects.create( + partner_program=program, + project=project, + ) + + profile = PartnerProgramUserProfile.objects.filter( + user=user, + partner_program=program, + ).first() + if profile: + profile.project = project + profile.save(update_fields=["project"]) + + value_objs = [ + PartnerProgramFieldValue( + program_project=program_link, + field=item["field"], + value_text=item.get("value_text") or "", + ) + for item in values_data + ] + if value_objs: + PartnerProgramFieldValue.objects.bulk_create(value_objs) + + return ProgramProjectApplicationResult(project=project, program_link=program_link) + + +def get_filterable_program_fields(program: PartnerProgram): + return PartnerProgramField.objects.filter( + partner_program=program, + show_filter=True, + ) + + +def validate_program_project_filters( + *, + program: PartnerProgram, + filters: dict[str, list[str]], +) -> None: + field_names = list(filters.keys()) + field_qs = PartnerProgramField.objects.filter( + partner_program=program, + name__in=field_names, + ) + field_by_name = {field.name: field for field in field_qs} + + missing = [name for name in field_names if name not in field_by_name] + if missing: + raise ProgramProjectFilterError( + {"detail": f"Поля не найденные в программе: {missing}"} + ) + + for field_name, values in filters.items(): + field_obj = field_by_name[field_name] + if not field_obj.show_filter: + raise ProgramProjectFilterError( + { + "detail": ( + f"Поле '{field_name}' недоступно для фильтрации " + "(show_filter=False)." + ) + } + ) + + options = field_obj.get_options_list() + if not options: + raise ProgramProjectFilterError( + {"detail": f"Поле '{field_name}' не имеет вариантов (options)."} + ) + + invalid_values = [value for value in values if value not in options] + if invalid_values: + raise ProgramProjectFilterError( + { + "detail": f"Неверные значения для поля '{field_name}'.", + "invalid": invalid_values, + } + ) + + +def get_filtered_program_project_links( + *, + program: PartnerProgram, + filters: dict[str, list[str]], +): + validate_program_project_filters(program=program, filters=filters) + + qs = PartnerProgramProject.objects.filter(partner_program=program) + if not filters: + return qs.select_related("project").distinct() + + for field_name, values in filters.items(): + field = PartnerProgramField.objects.get( + partner_program=program, + name=field_name.strip(), + ) + field_value_exists = PartnerProgramFieldValue.objects.filter( + program_project=OuterRef("pk"), + field=field, + value_text__in=values, + ) + qs = qs.filter(Exists(field_value_exists)) + + return qs.select_related("project").distinct() def publish_finished_program_projects(now=None) -> int: @@ -286,6 +626,77 @@ def row_dict_for_link( return row +def build_program_projects_export_file( + *, + program: PartnerProgram, + only_submitted: bool, +) -> ProgramExportFile: + extra_cols = build_program_field_columns(program) + header_pairs = BASE_COLUMNS + extra_cols + + field_values_qs = PartnerProgramFieldValue.objects.select_related("field").filter( + field__partner_program_id=program.id + ) + links_qs = program.program_projects.select_related( + "project", + "project__leader", + ).prefetch_related( + Prefetch( + "field_values", + queryset=field_values_qs, + to_attr="_prefetched_field_values", + ), + Prefetch( + "project__collaborator_set", + queryset=Collaborator.objects.select_related("user"), + to_attr="_prefetched_collaborators", + ), + ) + if only_submitted: + links_qs = links_qs.filter(submitted=True) + + workbook = Workbook(write_only=True) + worksheet = workbook.create_sheet(title="Проекты") + worksheet.append([title for _, title in header_pairs]) + + extra_keys_order = [key for key, _ in extra_cols] + for row_number, program_project_link in enumerate(links_qs, start=1): + row_dict = row_dict_for_link( + program_project_link=program_project_link, + extra_field_keys_order=extra_keys_order, + row_number=row_number, + ) + raw_values = [row_dict.get(key, "") for key, _ in header_pairs] + worksheet.append([sanitize_excel_value(value) for value in raw_values]) + + buffer = io.BytesIO() + workbook.save(buffer) + buffer.seek(0) + + label = "projects_review" if only_submitted else "projects" + date_suffix = timezone.now().strftime("%d.%m.%y") + base_name = f"{label} - {program.name or 'program'} - {date_suffix}" + return ProgramExportFile(binary_data=buffer.getvalue(), base_name=base_name) + + +def build_program_project_scores_export_file( + *, + program: PartnerProgram, +) -> ProgramExportFile: + rates_data_to_write = prepare_project_scores_export_data(program.id) + xlsx_file_writer = XlsxFileToExport() + xlsx_file_writer.write_data_to_xlsx(rates_data_to_write) + binary_data_to_export: bytes = xlsx_file_writer.get_binary_data_from_self_file() + xlsx_file_writer.clear_buffer() + + date_suffix = timezone.now().strftime("%d.%m.%y") + base_name = f"scores - {program.name or 'program'} - {date_suffix}" + return ProgramExportFile( + binary_data=binary_data_to_export, + base_name=base_name, + ) + + def prepare_project_scores_export_data(program_id: int) -> list[dict]: """ Готовит данные для выгрузки оценок проектов. diff --git a/partner_programs/tests/test_program_api.py b/partner_programs/tests/test_program_api.py index e9989b3b..8560cd75 100644 --- a/partner_programs/tests/test_program_api.py +++ b/partner_programs/tests/test_program_api.py @@ -57,7 +57,7 @@ def setUp(self): self.user = create_user(prefix="program-register-user") self.program = create_partner_program() - @patch("partner_programs.views.send_email.delay") + @patch("partner_programs.services.send_email.delay") def test_authenticated_user_can_register_to_program(self, send_email_delay): self.client.force_authenticate(self.user) @@ -75,7 +75,7 @@ def test_authenticated_user_can_register_to_program(self, send_email_delay): self.assertEqual(profile.partner_program_data, {"telegram": "@program_user"}) send_email_delay.assert_called_once() - @patch("partner_programs.views.send_email.delay") + @patch("partner_programs.services.send_email.delay") def test_authenticated_user_cannot_register_twice(self, send_email_delay): create_program_member(self.program, user=self.user) self.client.force_authenticate(self.user) @@ -93,7 +93,7 @@ def test_authenticated_user_cannot_register_twice(self, send_email_delay): ) send_email_delay.assert_not_called() - @patch("partner_programs.views.send_email.delay") + @patch("partner_programs.services.send_email.delay") def test_registration_is_blocked_after_deadline(self, send_email_delay): self.program.datetime_registration_ends = timezone.now() - timezone.timedelta( days=1 @@ -111,7 +111,7 @@ def test_registration_is_blocked_after_deadline(self, send_email_delay): self.assertEqual(response.data["detail"], "Registration period has ended.") send_email_delay.assert_not_called() - @patch("partner_programs.views.send_email.delay") + @patch("partner_programs.services.send_email.delay") def test_external_registration_creates_user_and_program_profile( self, send_email_delay, @@ -137,3 +137,108 @@ def test_external_registration_creates_user_and_program_profile( self.assertTrue(profile.user.is_active) self.assertEqual(profile.partner_program_data["telegram"], "@external") send_email_delay.assert_called_once() + + @patch("partner_programs.services.send_email.delay") + def test_external_registration_accepts_email_compatibility_field( + self, + send_email_delay, + ): + response = self.client.post( + f"/programs/{self.program.id}/register_new/", + { + "email_": "external-email-field@example.com", + "password": "pass", + "first_name": "External", + "last_name": "User", + "birthday": "01-01-1990", + "telegram": "@external_email_field", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + profile = PartnerProgramUserProfile.objects.select_related("user").get( + partner_program=self.program, + user__email="external-email-field@example.com", + ) + self.assertEqual(profile.partner_program_data["email_"], profile.user.email) + self.assertEqual( + profile.partner_program_data["telegram"], + "@external_email_field", + ) + send_email_delay.assert_called_once() + + @patch("partner_programs.services.send_email.delay") + def test_external_registration_test_ping_does_not_create_profile( + self, + send_email_delay, + ): + response = self.client.post( + f"/programs/{self.program.id}/register_new/", + {"test": "test"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + send_email_delay.assert_not_called() + + @patch("partner_programs.services.send_email.delay") + def test_external_registration_requires_email(self, send_email_delay): + response = self.client.post( + f"/programs/{self.program.id}/register_new/", + { + "password": "pass", + "first_name": "External", + "last_name": "User", + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["detail"], "You need to pass an email address.") + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + send_email_delay.assert_not_called() + + @patch("partner_programs.services.send_email.delay") + def test_external_registration_requires_password(self, send_email_delay): + response = self.client.post( + f"/programs/{self.program.id}/register_new/", + { + "email": "external-no-password@example.com", + "first_name": "External", + "last_name": "User", + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["detail"], "You need to pass a password.") + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + send_email_delay.assert_not_called() + + @patch("partner_programs.services.send_email.delay") + def test_external_registration_rejects_duplicate_program_profile( + self, + send_email_delay, + ): + create_program_member(self.program, user=self.user) + + response = self.client.post( + f"/programs/{self.program.id}/register_new/", + { + "email": self.user.email, + "password": "pass", + "first_name": "External", + "last_name": "User", + "birthday": "01-01-1990", + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["detail"], + "User has already registered in this program.", + ) + send_email_delay.assert_not_called() diff --git a/partner_programs/tests/test_program_filters.py b/partner_programs/tests/test_program_filters.py index 2c5d8452..8416e988 100644 --- a/partner_programs/tests/test_program_filters.py +++ b/partner_programs/tests/test_program_filters.py @@ -1,4 +1,7 @@ +import io + from django.test import TestCase +from openpyxl import load_workbook from rest_framework.test import APIClient from partner_programs.models import PartnerProgramFieldValue @@ -102,6 +105,55 @@ def test_filter_rejects_field_that_is_not_filterable(self): self.assertEqual(response.status_code, 400) + def test_filter_rejects_unknown_program_field(self): + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.id}/projects/filter/", + {"filters": {"missing": ["yes"]}}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("Поля не найденные", response.data["detail"]) + + def test_filter_rejects_value_outside_field_options(self): + create_program_field( + self.program, + name="track", + field_type="select", + options=["ai", "edu"], + show_filter=True, + ) + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.id}/projects/filter/", + {"filters": {"track": ["wrong"]}}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["invalid"], ["wrong"]) + + def test_filter_rejects_filterable_field_without_options(self): + create_program_field( + self.program, + name="comment", + field_type="text", + show_filter=True, + ) + self.client.force_authenticate(self.manager) + + response = self.client.post( + f"/programs/{self.program.id}/projects/filter/", + {"filters": {"comment": ["value"]}}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("не имеет вариантов", response.data["detail"]) + class PartnerProgramProjectsAPITests(TestCase): def setUp(self): @@ -128,3 +180,66 @@ def test_non_manager_cannot_get_program_projects(self): response = self.client.get(f"/programs/{self.program.id}/projects/") self.assertEqual(response.status_code, 403) + + +class PartnerProgramProjectExportAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="program-export-manager") + self.program = create_partner_program(name="Export Program") + self.program.managers.add(self.manager) + + def test_manager_can_export_program_projects_to_xlsx(self): + field = create_program_field( + self.program, + name="track", + label="Track", + ) + submitted_project = create_project(name="Submitted project") + draft_project = create_project(name="Draft project") + submitted_link = create_program_project( + self.program, + project=submitted_project, + submitted=True, + ) + draft_link = create_program_project( + self.program, + project=draft_project, + submitted=False, + ) + PartnerProgramFieldValue.objects.create( + program_project=submitted_link, + field=field, + value_text="ai", + ) + PartnerProgramFieldValue.objects.create( + program_project=draft_link, + field=field, + value_text="edu", + ) + self.client.force_authenticate(self.manager) + + response = self.client.get( + f"/programs/{self.program.id}/export-projects/", + {"only_submitted": "true"}, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn(".xlsx", response["Content-Disposition"]) + + workbook = load_workbook(io.BytesIO(response.content), read_only=True) + rows = list(workbook["Проекты"].iter_rows(values_only=True)) + workbook.close() + + header = rows[0] + project_names = [row[1] for row in rows[1:]] + self.assertIn("Track", header) + self.assertEqual(project_names, ["Submitted project"]) + + def test_non_manager_cannot_export_program_projects(self): + outsider = create_user(prefix="program-export-outsider") + self.client.force_authenticate(outsider) + + response = self.client.get(f"/programs/{self.program.id}/export-projects/") + + self.assertEqual(response.status_code, 403) diff --git a/partner_programs/tests/test_project_apply.py b/partner_programs/tests/test_project_apply.py index f4f675ca..6dc3563c 100644 --- a/partner_programs/tests/test_project_apply.py +++ b/partner_programs/tests/test_project_apply.py @@ -7,6 +7,8 @@ create_partner_program, create_program_field, create_program_member, + create_program_project, + create_project, create_user, project_apply_payload, ) @@ -90,6 +92,48 @@ def test_member_can_apply_project_with_program_field_values(self): profile.refresh_from_db() self.assertEqual(profile.project, program_link.project) + def test_manager_can_apply_project_without_program_profile(self): + manager = create_user(prefix="program-apply-manager") + self.program.managers.add(manager) + self.client.force_authenticate(manager) + + response = self.client.post( + f"/programs/{self.program.id}/projects/apply/", + project_apply_payload(project={"name": "Manager project"}), + format="json", + ) + + self.assertEqual(response.status_code, 201) + program_link = PartnerProgramProject.objects.select_related("project").get( + id=response.data["program_link_id"] + ) + self.assertEqual(program_link.project.leader, manager) + self.assertEqual(program_link.project.name, "Manager project") + + def test_apply_project_rejects_duplicate_project_for_same_leader(self): + create_program_member(self.program, user=self.user) + existing_project = create_project( + leader=self.user, + name="Existing program project", + ) + existing_link = create_program_project( + self.program, + project=existing_project, + ) + self.client.force_authenticate(self.user) + + response = self.client.post( + f"/programs/{self.program.id}/projects/apply/", + project_apply_payload(project={"name": "Second project"}), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["detail"], "Проект уже подан в эту программу.") + self.assertEqual(response.data["project_id"], existing_project.id) + self.assertEqual(response.data["program_link_id"], existing_link.id) + self.assertEqual(PartnerProgramProject.objects.count(), 1) + def test_apply_project_rejects_duplicate_field_values(self): create_program_member(self.program, user=self.user) field = create_program_field(self.program, name="track") diff --git a/partner_programs/views.py b/partner_programs/views.py index 288d7fa4..b778865a 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -1,14 +1,11 @@ -import io - from django.contrib.auth import get_user_model -from django.db import IntegrityError, transaction -from django.db.models import Exists, OuterRef, Prefetch +from django.db import transaction +from django.db.models import Exists, OuterRef from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.timezone import now from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from openpyxl import Workbook from rest_framework import generics, permissions, status from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.generics import GenericAPIView @@ -18,15 +15,9 @@ from core.serializers import EmptySerializer, SetLikedSerializer, SetViewedSerializer from core.services import add_view, set_like -from core.utils import ( - XlsxFileToExport, - build_xlsx_download_response, - sanitize_excel_value, -) -from partner_programs.helpers import date_to_iso +from core.utils import build_xlsx_download_response from partner_programs.models import ( PartnerProgram, - PartnerProgramField, PartnerProgramFieldValue, PartnerProgramProject, PartnerProgramUserProfile, @@ -48,17 +39,21 @@ ProgramProjectFilterRequestSerializer, ) from partner_programs.services import ( - BASE_COLUMNS, - build_program_field_columns, - prepare_project_scores_export_data, - row_dict_for_link, + ProgramProjectAlreadyApplied, + ProgramProjectFilterError, + ProgramRegistrationError, + apply_project_to_program, + build_program_project_scores_export_file, + build_program_projects_export_file, + create_user_and_register_to_program, + get_filterable_program_fields, + get_filtered_program_project_links, + register_user_to_program, + require_can_apply_project_to_program, ) -from partner_programs.utils import filter_program_projects_by_field_name -from projects.models import Collaborator, Project from partner_programs.serializers import PartnerProgramFieldValueUpdateSerializer +from projects.models import Project from projects.serializers import ProjectListSerializer -from vacancy.mapping import MessageTypeEnum, UserProgramRegisterParams -from vacancy.tasks import send_email User = get_user_model() @@ -134,21 +129,9 @@ class PartnerProgramProjectApplyView(GenericAPIView): serializer_class = PartnerProgramProjectApplySerializer queryset = PartnerProgram.objects.all() - def _require_can_apply(self, program: PartnerProgram, user: User): - if not program.is_project_submission_open(): - raise ValidationError("Срок подачи проектов в программу завершён.") - - if program.is_manager(user): - return - - if not PartnerProgramUserProfile.objects.filter( - user=user, partner_program=program - ).exists(): - raise PermissionDenied("Подача проекта доступна только участникам программы.") - def get(self, request, pk, *args, **kwargs): program = self.get_object() - self._require_can_apply(program, request.user) + require_can_apply_project_to_program(program=program, user=request.user) fields_qs = program.fields.all() return Response( @@ -163,96 +146,27 @@ def get(self, request, pk, *args, **kwargs): def post(self, request, pk, *args, **kwargs): program = self.get_object() - self._require_can_apply(program, request.user) - - existing_link = ( - PartnerProgramProject.objects.select_related("project") - .filter(partner_program=program, project__leader=request.user) - .first() - ) - if existing_link: + try: + result = apply_project_to_program( + program=program, + user=request.user, + data=request.data, + serializer_class=self.get_serializer_class(), + ) + except ProgramProjectAlreadyApplied as exc: return Response( { "detail": "Проект уже подан в эту программу.", - "project_id": existing_link.project_id, - "program_link_id": existing_link.id, + "project_id": exc.program_link.project_id, + "program_link_id": exc.program_link.id, }, status=status.HTTP_400_BAD_REQUEST, ) - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - data = serializer.validated_data - - project_data = data["project"] - values_data = data.get("program_field_values") or [] - - seen_field_ids: set[int] = set() - duplicate_ids: set[int] = set() - for item in values_data: - field_id = item["field"].id - if field_id in seen_field_ids: - duplicate_ids.add(field_id) - seen_field_ids.add(field_id) - if duplicate_ids: - raise ValidationError( - {"program_field_values": f"Есть повторяющиеся field_id: {sorted(duplicate_ids)}"} - ) - - required_fields = list( - program.fields.filter(is_required=True).values("id", "label") - ) - provided_field_ids = {item["field"].id for item in values_data} - missing_required = [ - f["label"] for f in required_fields if f["id"] not in provided_field_ids - ] - if missing_required: - raise ValidationError( - {"program_field_values": f"Не заполнены обязательные поля: {missing_required}"} - ) - - with transaction.atomic(): - project = Project.objects.create( - leader=request.user, - draft=True, - is_public=False, - **project_data, - ) - program_link = PartnerProgramProject.objects.create( - partner_program=program, project=project - ) - - profile = PartnerProgramUserProfile.objects.filter( - user=request.user, partner_program=program - ).first() - if profile: - profile.project = project - profile.save(update_fields=["project"]) - - value_objs: list[PartnerProgramFieldValue] = [] - for item in values_data: - field = item["field"] - if field.partner_program_id != program.id: - raise ValidationError( - { - "program_field_values": f"Поле id={field.id} не относится к этой программе." - } - ) - value_objs.append( - PartnerProgramFieldValue( - program_project=program_link, - field=field, - value_text=item.get("value_text") or "", - ) - ) - - if value_objs: - PartnerProgramFieldValue.objects.bulk_create(value_objs) - return Response( { - "project_id": project.id, - "program_link_id": program_link.id, + "project_id": result.project.id, + "program_link_id": result.program_link.id, }, status=status.HTTP_201_CREATED, ) @@ -275,69 +189,18 @@ def post(self, request, *args, **kwargs): if data.get("test") == "test": return Response(status=status.HTTP_200_OK) + program = self.get_object() try: - program = self.get_object() - except PartnerProgram.DoesNotExist: - return Response({"asd": "asd"}, status=status.HTTP_404_NOT_FOUND) - - # tilda cringe - email = data.get("email") if data.get("email") else data.get("email_") - if not email: - return Response( - data={"detail": "You need to pass an email address."}, - status=status.HTTP_400_BAD_REQUEST, - ) - password = data.get("password") - if not password: - return Response( - data={"detail": "You need to pass a password."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user_fields = ( - "first_name", - "last_name", - "patronymic", - "city", - ) - user, created = User.objects.get_or_create( - email=email, - defaults={ - "birthday": date_to_iso(data.get("birthday", "01-01-1900")), - "is_active": True, # bypass email verification - "onboarding_stage": None, # bypass onboarding - "verification_date": timezone.now(), # bypass manual verification - **{field_name: data.get(field_name, "") for field_name in user_fields}, - }, - ) - if created: # Only when registering a new user. - user.set_password(password) - user.save() - - user_profile_program_data = { - k: v for k, v in data.items() if k not in user_fields and k != "password" - } - try: - PartnerProgramUserProfile.objects.create( - partner_program_data=user_profile_program_data, - user=user, - partner_program=program, + create_user_and_register_to_program( + program=program, + data=data, ) - except IntegrityError: + except ProgramRegistrationError as exc: return Response( - data={"detail": "User has already registered in this program."}, + data={"detail": exc.detail}, status=status.HTTP_400_BAD_REQUEST, ) - send_email.delay( - UserProgramRegisterParams( - message_type=MessageTypeEnum.REGISTERED_PROGRAM_USER.value, - user_id=user.id, - program_name=program.name, - program_id=program.id, - schema_id=2, - ) - ) return Response(status=status.HTTP_201_CREATED) def get(self, request, *args, **kwargs): @@ -354,41 +217,19 @@ class PartnerProgramRegister(generics.GenericAPIView): serializer_class = PartnerProgramUserSerializer def post(self, request, *args, **kwargs): + program = self.get_object() try: - program = self.get_object() - if program.datetime_registration_ends < timezone.now(): - return Response( - data={"detail": "Registration period has ended."}, - status=status.HTTP_400_BAD_REQUEST, - ) - user_to_add = request.user - user_profile_program_data = request.data - - added_user_profile = PartnerProgramUserProfile( - partner_program_data=user_profile_program_data, - user=user_to_add, - partner_program=program, + register_user_to_program( + program=program, + user=request.user, + data=request.data, ) - added_user_profile.save() - - send_email.delay( - UserProgramRegisterParams( - message_type=MessageTypeEnum.REGISTERED_PROGRAM_USER.value, - user_id=user_to_add.id, - program_name=program.name, - program_id=program.id, - schema_id=2, - ) - ) - - return Response(status=status.HTTP_201_CREATED) - except PartnerProgram.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) - except IntegrityError: + except ProgramRegistrationError as exc: return Response( - data={"detail": "User already registered to this program."}, + data={"detail": exc.detail}, status=status.HTTP_400_BAD_REQUEST, ) + return Response(status=status.HTTP_201_CREATED) class PartnerProgramSetViewed(generics.GenericAPIView): @@ -540,9 +381,7 @@ class ProgramFiltersAPIView(APIView): def get(self, request, pk): program = get_object_or_404(PartnerProgram, pk=pk) - fields = PartnerProgramField.objects.filter( - partner_program=program, show_filter=True - ) + fields = get_filterable_program_fields(program) serializer = PartnerProgramFieldSerializer(fields, many=True) return Response(serializer.data) @@ -560,47 +399,10 @@ def post(self, request, pk): program = get_object_or_404(PartnerProgram, pk=pk) filters = data.get("filters", {}) - - field_names = list(filters.keys()) - field_qs = PartnerProgramField.objects.filter( - partner_program=program, name__in=field_names - ) - field_by_name = {f.name: f for f in field_qs} - - missing = [name for name in field_names if name not in field_by_name] - if missing: - return Response( - {"detail": f"Поля не найденные в программе: {missing}"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - for field_name, values in filters.items(): - field_obj = field_by_name[field_name] - if not field_obj.show_filter: - return Response( - { - "detail": f"Поле '{field_name}' недоступно для фильтрации (show_filter=False)." - }, - status=status.HTTP_400_BAD_REQUEST, - ) - opts = field_obj.get_options_list() - if opts: - invalid_values = [val for val in values if val not in opts] - if invalid_values: - return Response( - { - "detail": f"Неверные значения для поля '{field_name}'.", - "invalid": invalid_values, - }, - status=status.HTTP_400_BAD_REQUEST, - ) - else: - return Response( - {"detail": f"Поле '{field_name}' не имеет вариантов (options)."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - qs = filter_program_projects_by_field_name(program, filters) + try: + qs = get_filtered_program_project_links(program=program, filters=filters) + except ProgramProjectFilterError as exc: + return Response(exc.detail, status=status.HTTP_400_BAD_REQUEST) paginator = self.pagination_class() page = paginator.paginate_queryset(qs, request, view=self) @@ -652,15 +454,11 @@ def get(self, request, pk: int): {"detail": "Недостаточно прав."}, status=status.HTTP_403_FORBIDDEN ) - rates_data_to_write = prepare_project_scores_export_data(program.id) - xlsx_file_writer = XlsxFileToExport() - xlsx_file_writer.write_data_to_xlsx(rates_data_to_write) - binary_data_to_export: bytes = xlsx_file_writer.get_binary_data_from_self_file() - xlsx_file_writer.clear_buffer() - - date_suffix = timezone.now().strftime("%d.%m.%y") - base_name = f"scores - {program.name or 'program'} - {date_suffix}" - return build_xlsx_download_response(binary_data_to_export, base_name=base_name) + export_file = build_program_project_scores_export_file(program=program) + return build_xlsx_download_response( + export_file.binary_data, + base_name=export_file.base_name, + ) class PartnerProgramExportProjectsAPIView(APIView): @@ -681,51 +479,6 @@ def _has_access(self, user, program: PartnerProgram) -> bool: or program.is_manager(user) ) - def _export(self, program: PartnerProgram, only_submitted: bool): - extra_cols = build_program_field_columns(program) - header_pairs = BASE_COLUMNS + extra_cols - - fv_qs = PartnerProgramFieldValue.objects.select_related("field").filter( - field__partner_program_id=program.id - ) - links_qs = program.program_projects.select_related( - "project", "project__leader" - ).prefetch_related( - Prefetch("field_values", queryset=fv_qs, to_attr="_prefetched_field_values"), - Prefetch( - "project__collaborator_set", - queryset=Collaborator.objects.select_related("user"), - to_attr="_prefetched_collaborators", - ), - ) - if only_submitted: - links_qs = links_qs.filter(submitted=True) - - wb = Workbook(write_only=True) - ws = wb.create_sheet(title="Проекты") - ws.append([title for _, title in header_pairs]) - - extra_keys_order = [key for key, _ in extra_cols] - - for row_number, program_project_link in enumerate(links_qs, start=1): - row_dict = row_dict_for_link( - program_project_link=program_project_link, - extra_field_keys_order=extra_keys_order, - row_number=row_number, - ) - raw_values = [row_dict.get(key, "") for key, _ in header_pairs] - safe_values = [sanitize_excel_value(v) for v in raw_values] - ws.append(safe_values) - - bio = io.BytesIO() - wb.save(bio) - bio.seek(0) - - label = "projects_review" if only_submitted else "projects" - date_suffix = timezone.now().strftime("%d.%m.%y") - base_name = f"{label} - {program.name or 'program'} - {date_suffix}" - return build_xlsx_download_response(bio.getvalue(), base_name=base_name) - def get(self, request, pk: int): program = self._get_program(pk) if not program: @@ -743,4 +496,11 @@ def get(self, request, pk: int): "true", "True", ) - return self._export(program=program, only_submitted=only_submitted) + export_file = build_program_projects_export_file( + program=program, + only_submitted=only_submitted, + ) + return build_xlsx_download_response( + export_file.binary_data, + base_name=export_file.base_name, + ) From be1cbab7a90d060256adf6d1fa1609174151a3d6 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 27 May 2026 11:28:55 +0500 Subject: [PATCH 18/32] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=91=D0=BD=20service=20layer=20=D0=BF=D0=B0=D1=80=D1=82=D0=BD?= =?UTF-8?q?=D1=91=D1=80=D1=81=D0=BA=D0=B8=D1=85=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=BC=20=D0=B8=20=D1=80=D0=B0=D1=81=D1=88?= =?UTF-8?q?=D0=B8=D1=80=D0=B5=D0=BD=D1=8B=20=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 --- docs/modules/partner-programs.md | 6 +- partner_programs/services/__init__.py | 51 +++ .../{services.py => services/exports.py} | 363 +----------------- partner_programs/services/project_apply.py | 156 ++++++++ partner_programs/services/project_filters.py | 93 +++++ partner_programs/services/publishing.py | 35 ++ partner_programs/services/registration.py | 107 ++++++ partner_programs/tests/test_exports.py | 173 +++++++++ partner_programs/tests/test_permissions.py | 71 ++++ partner_programs/tests/test_program_api.py | 18 +- 10 files changed, 703 insertions(+), 370 deletions(-) create mode 100644 partner_programs/services/__init__.py rename partner_programs/{services.py => services/exports.py} (59%) create mode 100644 partner_programs/services/project_apply.py create mode 100644 partner_programs/services/project_filters.py create mode 100644 partner_programs/services/publishing.py create mode 100644 partner_programs/services/registration.py create mode 100644 partner_programs/tests/test_exports.py create mode 100644 partner_programs/tests/test_permissions.py diff --git a/docs/modules/partner-programs.md b/docs/modules/partner-programs.md index 7a4708d4..d31bb234 100644 --- a/docs/modules/partner-programs.md +++ b/docs/modules/partner-programs.md @@ -36,7 +36,7 @@ фильтров и экспортов. - `partner_programs/serializers/` - request/response serializers и validation дополнительных полей. -- `partner_programs/services.py` - сервисы регистрации в программе, подачи +- `partner_programs/services/` - сервисы регистрации в программе, подачи проектов, фильтрации проектов, публикации проектов и подготовки Excel-выгрузок. - `partner_programs/selectors.py` - выборки участников для аналитики и @@ -205,4 +205,8 @@ - список проектов программы для менеджера; - Excel-выгрузку проектов программы, включая режим `only_submitted`; - запрет выгрузки проектов пользователем без прав менеджера; +- Excel-выгрузку оценок проектов программы; +- подготовку данных выгрузки оценок, когда критерии есть, но оценок еще нет; +- базовые permissions для менеджера программы, staff-пользователя, + постороннего пользователя, anonymous и лидера проекта; - публикацию проектов после завершения программы. diff --git a/partner_programs/services/__init__.py b/partner_programs/services/__init__.py new file mode 100644 index 00000000..828cde87 --- /dev/null +++ b/partner_programs/services/__init__.py @@ -0,0 +1,51 @@ +from partner_programs.services.exports import ( + BASE_COLUMNS, + ProgramExportFile, + ProjectScoreDataPreparer, + build_program_field_columns, + build_program_project_scores_export_file, + build_program_projects_export_file, + prepare_project_scores_export_data, + row_dict_for_link, +) +from partner_programs.services.project_apply import ( + ProgramProjectAlreadyApplied, + ProgramProjectApplicationResult, + apply_project_to_program, + require_can_apply_project_to_program, +) +from partner_programs.services.project_filters import ( + ProgramProjectFilterError, + get_filterable_program_fields, + get_filtered_program_project_links, + validate_program_project_filters, +) +from partner_programs.services.publishing import publish_finished_program_projects +from partner_programs.services.registration import ( + ProgramRegistrationError, + create_user_and_register_to_program, + register_user_to_program, +) + +__all__ = [ + "BASE_COLUMNS", + "ProgramExportFile", + "ProgramProjectAlreadyApplied", + "ProgramProjectApplicationResult", + "ProgramProjectFilterError", + "ProgramRegistrationError", + "ProjectScoreDataPreparer", + "apply_project_to_program", + "build_program_field_columns", + "build_program_project_scores_export_file", + "build_program_projects_export_file", + "create_user_and_register_to_program", + "get_filterable_program_fields", + "get_filtered_program_project_links", + "prepare_project_scores_export_data", + "publish_finished_program_projects", + "register_user_to_program", + "require_can_apply_project_to_program", + "row_dict_for_link", + "validate_program_project_filters", +] diff --git a/partner_programs/services.py b/partner_programs/services/exports.py similarity index 59% rename from partner_programs/services.py rename to partner_programs/services/exports.py index f0023c1d..e2c153ef 100644 --- a/partner_programs/services.py +++ b/partner_programs/services/exports.py @@ -1,17 +1,13 @@ -import logging import io +import logging from collections import OrderedDict from dataclasses import dataclass -from django.contrib.auth import get_user_model -from django.db import IntegrityError, transaction -from django.db.models import Exists, OuterRef, Prefetch +from django.db.models import Prefetch from django.utils import timezone from openpyxl import Workbook -from rest_framework.exceptions import PermissionDenied, ValidationError from core.utils import XlsxFileToExport, sanitize_excel_value -from partner_programs.helpers import date_to_iso from partner_programs.models import ( PartnerProgram, PartnerProgramField, @@ -20,43 +16,9 @@ PartnerProgramUserProfile, ) from project_rates.models import Criteria, ProjectScore -from projects.models import Collaborator, Project -from vacancy.mapping import MessageTypeEnum, UserProgramRegisterParams -from vacancy.tasks import send_email +from projects.models import Collaborator logger = logging.getLogger() -User = get_user_model() - -EXTERNAL_REGISTRATION_USER_FIELDS = ( - "first_name", - "last_name", - "patronymic", - "city", -) - - -class ProgramRegistrationError(Exception): - def __init__(self, detail: str): - self.detail = detail - super().__init__(detail) - - -class ProgramProjectAlreadyApplied(Exception): - def __init__(self, program_link: PartnerProgramProject): - self.program_link = program_link - super().__init__("Проект уже подан в эту программу.") - - -class ProgramProjectFilterError(Exception): - def __init__(self, detail: dict): - self.detail = detail - super().__init__(str(detail)) - - -@dataclass(frozen=True) -class ProgramProjectApplicationResult: - project: Project - program_link: PartnerProgramProject @dataclass(frozen=True) @@ -65,325 +27,6 @@ class ProgramExportFile: base_name: str -def _send_program_registration_email(user, program: PartnerProgram) -> None: - send_email.delay( - UserProgramRegisterParams( - message_type=MessageTypeEnum.REGISTERED_PROGRAM_USER.value, - user_id=user.id, - program_name=program.name, - program_id=program.id, - schema_id=2, - ) - ) - - -def register_user_to_program( - *, - program: PartnerProgram, - user: User, - data, -) -> PartnerProgramUserProfile: - if program.datetime_registration_ends < timezone.now(): - raise ProgramRegistrationError("Registration period has ended.") - - try: - user_profile = PartnerProgramUserProfile.objects.create( - partner_program_data=data, - user=user, - partner_program=program, - ) - except IntegrityError: - raise ProgramRegistrationError("User already registered to this program.") - - _send_program_registration_email(user, program) - return user_profile - - -def create_user_and_register_to_program( - *, - program: PartnerProgram, - data, -) -> PartnerProgramUserProfile: - email = data.get("email") if data.get("email") else data.get("email_") - if not email: - raise ProgramRegistrationError("You need to pass an email address.") - - password = data.get("password") - if not password: - raise ProgramRegistrationError("You need to pass a password.") - - user, created = User.objects.get_or_create( - email=email, - defaults={ - "birthday": date_to_iso(data.get("birthday", "01-01-1900")), - "is_active": True, # bypass email verification for external forms - "onboarding_stage": None, # bypass onboarding for external forms - "verification_date": timezone.now(), # bypass manual verification - **{ - field_name: data.get(field_name, "") - for field_name in EXTERNAL_REGISTRATION_USER_FIELDS - }, - }, - ) - if created: - user.set_password(password) - user.save() - - user_profile_program_data = { - k: v - for k, v in data.items() - if k not in EXTERNAL_REGISTRATION_USER_FIELDS and k != "password" - } - try: - user_profile = PartnerProgramUserProfile.objects.create( - partner_program_data=user_profile_program_data, - user=user, - partner_program=program, - ) - except IntegrityError: - raise ProgramRegistrationError( - "User has already registered in this program." - ) - - _send_program_registration_email(user, program) - return user_profile - - -def require_can_apply_project_to_program( - *, - program: PartnerProgram, - user: User, -) -> None: - if not program.is_project_submission_open(): - raise ValidationError("Срок подачи проектов в программу завершён.") - - if program.is_manager(user): - return - - if not PartnerProgramUserProfile.objects.filter( - user=user, - partner_program=program, - ).exists(): - raise PermissionDenied("Подача проекта доступна только участникам программы.") - - -def _validate_unique_program_fields(values_data: list[dict]) -> None: - seen_field_ids: set[int] = set() - duplicate_ids: set[int] = set() - for item in values_data: - field_id = item["field"].id - if field_id in seen_field_ids: - duplicate_ids.add(field_id) - seen_field_ids.add(field_id) - if duplicate_ids: - raise ValidationError( - {"program_field_values": f"Есть повторяющиеся field_id: {sorted(duplicate_ids)}"} - ) - - -def _validate_required_program_fields( - *, - program: PartnerProgram, - values_data: list[dict], -) -> None: - required_fields = list( - program.fields.filter(is_required=True).values("id", "label") - ) - provided_field_ids = {item["field"].id for item in values_data} - missing_required = [ - field["label"] - for field in required_fields - if field["id"] not in provided_field_ids - ] - if missing_required: - raise ValidationError( - {"program_field_values": f"Не заполнены обязательные поля: {missing_required}"} - ) - - -def _validate_program_field_ownership( - *, - program: PartnerProgram, - values_data: list[dict], -) -> None: - for item in values_data: - field = item["field"] - if field.partner_program_id != program.id: - raise ValidationError( - { - "program_field_values": f"Поле id={field.id} не относится к этой программе." - } - ) - - -def apply_project_to_program( - *, - program: PartnerProgram, - user: User, - data, - serializer_class, -) -> ProgramProjectApplicationResult: - require_can_apply_project_to_program(program=program, user=user) - - existing_link = ( - PartnerProgramProject.objects.select_related("project") - .filter(partner_program=program, project__leader=user) - .first() - ) - if existing_link: - raise ProgramProjectAlreadyApplied(existing_link) - - serializer = serializer_class(data=data) - serializer.is_valid(raise_exception=True) - validated_data = serializer.validated_data - - project_data = validated_data["project"] - values_data = validated_data.get("program_field_values") or [] - - _validate_unique_program_fields(values_data) - _validate_required_program_fields(program=program, values_data=values_data) - _validate_program_field_ownership(program=program, values_data=values_data) - - with transaction.atomic(): - project = Project.objects.create( - leader=user, - draft=True, - is_public=False, - **project_data, - ) - program_link = PartnerProgramProject.objects.create( - partner_program=program, - project=project, - ) - - profile = PartnerProgramUserProfile.objects.filter( - user=user, - partner_program=program, - ).first() - if profile: - profile.project = project - profile.save(update_fields=["project"]) - - value_objs = [ - PartnerProgramFieldValue( - program_project=program_link, - field=item["field"], - value_text=item.get("value_text") or "", - ) - for item in values_data - ] - if value_objs: - PartnerProgramFieldValue.objects.bulk_create(value_objs) - - return ProgramProjectApplicationResult(project=project, program_link=program_link) - - -def get_filterable_program_fields(program: PartnerProgram): - return PartnerProgramField.objects.filter( - partner_program=program, - show_filter=True, - ) - - -def validate_program_project_filters( - *, - program: PartnerProgram, - filters: dict[str, list[str]], -) -> None: - field_names = list(filters.keys()) - field_qs = PartnerProgramField.objects.filter( - partner_program=program, - name__in=field_names, - ) - field_by_name = {field.name: field for field in field_qs} - - missing = [name for name in field_names if name not in field_by_name] - if missing: - raise ProgramProjectFilterError( - {"detail": f"Поля не найденные в программе: {missing}"} - ) - - for field_name, values in filters.items(): - field_obj = field_by_name[field_name] - if not field_obj.show_filter: - raise ProgramProjectFilterError( - { - "detail": ( - f"Поле '{field_name}' недоступно для фильтрации " - "(show_filter=False)." - ) - } - ) - - options = field_obj.get_options_list() - if not options: - raise ProgramProjectFilterError( - {"detail": f"Поле '{field_name}' не имеет вариантов (options)."} - ) - - invalid_values = [value for value in values if value not in options] - if invalid_values: - raise ProgramProjectFilterError( - { - "detail": f"Неверные значения для поля '{field_name}'.", - "invalid": invalid_values, - } - ) - - -def get_filtered_program_project_links( - *, - program: PartnerProgram, - filters: dict[str, list[str]], -): - validate_program_project_filters(program=program, filters=filters) - - qs = PartnerProgramProject.objects.filter(partner_program=program) - if not filters: - return qs.select_related("project").distinct() - - for field_name, values in filters.items(): - field = PartnerProgramField.objects.get( - partner_program=program, - name=field_name.strip(), - ) - field_value_exists = PartnerProgramFieldValue.objects.filter( - program_project=OuterRef("pk"), - field=field, - value_text__in=values, - ) - qs = qs.filter(Exists(field_value_exists)) - - return qs.select_related("project").distinct() - - -def publish_finished_program_projects(now=None) -> int: - if now is None: - now = timezone.now() - - program_ids = PartnerProgram.objects.filter( - publish_projects_after_finish=True, - datetime_finished__lte=now, - ).values_list("id", flat=True) - if not program_ids.exists(): - return 0 - - link_project_ids = PartnerProgramProject.objects.filter( - partner_program_id__in=program_ids - ).values_list("project_id", flat=True) - profile_project_ids = PartnerProgramUserProfile.objects.filter( - partner_program_id__in=program_ids, - project_id__isnull=False, - ).values_list("project_id", flat=True) - project_ids = link_project_ids.union(profile_project_ids) - - return Project.objects.filter( - id__in=project_ids, - is_public=False, - draft=False, - ).update(is_public=True) - - class ProjectScoreDataPreparer: """ Data preparer about project_rates by experts. diff --git a/partner_programs/services/project_apply.py b/partner_programs/services/project_apply.py new file mode 100644 index 00000000..747fd440 --- /dev/null +++ b/partner_programs/services/project_apply.py @@ -0,0 +1,156 @@ +from dataclasses import dataclass + +from django.contrib.auth import get_user_model +from django.db import transaction +from rest_framework.exceptions import PermissionDenied, ValidationError + +from partner_programs.models import ( + PartnerProgram, + PartnerProgramFieldValue, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from projects.models import Project + +User = get_user_model() + + +class ProgramProjectAlreadyApplied(Exception): + def __init__(self, program_link: PartnerProgramProject): + self.program_link = program_link + super().__init__("Проект уже подан в эту программу.") + + +@dataclass(frozen=True) +class ProgramProjectApplicationResult: + project: Project + program_link: PartnerProgramProject + + +def require_can_apply_project_to_program( + *, + program: PartnerProgram, + user: User, +) -> None: + if not program.is_project_submission_open(): + raise ValidationError("Срок подачи проектов в программу завершён.") + + if program.is_manager(user): + return + + if not PartnerProgramUserProfile.objects.filter( + user=user, + partner_program=program, + ).exists(): + raise PermissionDenied("Подача проекта доступна только участникам программы.") + + +def _validate_unique_program_fields(values_data: list[dict]) -> None: + seen_field_ids: set[int] = set() + duplicate_ids: set[int] = set() + for item in values_data: + field_id = item["field"].id + if field_id in seen_field_ids: + duplicate_ids.add(field_id) + seen_field_ids.add(field_id) + if duplicate_ids: + raise ValidationError( + {"program_field_values": f"Есть повторяющиеся field_id: {sorted(duplicate_ids)}"} + ) + + +def _validate_required_program_fields( + *, + program: PartnerProgram, + values_data: list[dict], +) -> None: + required_fields = list( + program.fields.filter(is_required=True).values("id", "label") + ) + provided_field_ids = {item["field"].id for item in values_data} + missing_required = [ + field["label"] + for field in required_fields + if field["id"] not in provided_field_ids + ] + if missing_required: + raise ValidationError( + {"program_field_values": f"Не заполнены обязательные поля: {missing_required}"} + ) + + +def _validate_program_field_ownership( + *, + program: PartnerProgram, + values_data: list[dict], +) -> None: + for item in values_data: + field = item["field"] + if field.partner_program_id != program.id: + raise ValidationError( + { + "program_field_values": f"Поле id={field.id} не относится к этой программе." + } + ) + + +def apply_project_to_program( + *, + program: PartnerProgram, + user: User, + data, + serializer_class, +) -> ProgramProjectApplicationResult: + require_can_apply_project_to_program(program=program, user=user) + + existing_link = ( + PartnerProgramProject.objects.select_related("project") + .filter(partner_program=program, project__leader=user) + .first() + ) + if existing_link: + raise ProgramProjectAlreadyApplied(existing_link) + + serializer = serializer_class(data=data) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + + project_data = validated_data["project"] + values_data = validated_data.get("program_field_values") or [] + + _validate_unique_program_fields(values_data) + _validate_required_program_fields(program=program, values_data=values_data) + _validate_program_field_ownership(program=program, values_data=values_data) + + with transaction.atomic(): + project = Project.objects.create( + leader=user, + draft=True, + is_public=False, + **project_data, + ) + program_link = PartnerProgramProject.objects.create( + partner_program=program, + project=project, + ) + + profile = PartnerProgramUserProfile.objects.filter( + user=user, + partner_program=program, + ).first() + if profile: + profile.project = project + profile.save(update_fields=["project"]) + + value_objs = [ + PartnerProgramFieldValue( + program_project=program_link, + field=item["field"], + value_text=item.get("value_text") or "", + ) + for item in values_data + ] + if value_objs: + PartnerProgramFieldValue.objects.bulk_create(value_objs) + + return ProgramProjectApplicationResult(project=project, program_link=program_link) diff --git a/partner_programs/services/project_filters.py b/partner_programs/services/project_filters.py new file mode 100644 index 00000000..d12f625a --- /dev/null +++ b/partner_programs/services/project_filters.py @@ -0,0 +1,93 @@ +from django.db.models import Exists, OuterRef + +from partner_programs.models import ( + PartnerProgram, + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, +) + + +class ProgramProjectFilterError(Exception): + def __init__(self, detail: dict): + self.detail = detail + super().__init__(str(detail)) + + +def get_filterable_program_fields(program: PartnerProgram): + return PartnerProgramField.objects.filter( + partner_program=program, + show_filter=True, + ) + + +def validate_program_project_filters( + *, + program: PartnerProgram, + filters: dict[str, list[str]], +) -> None: + field_names = list(filters.keys()) + field_qs = PartnerProgramField.objects.filter( + partner_program=program, + name__in=field_names, + ) + field_by_name = {field.name: field for field in field_qs} + + missing = [name for name in field_names if name not in field_by_name] + if missing: + raise ProgramProjectFilterError( + {"detail": f"Поля не найденные в программе: {missing}"} + ) + + for field_name, values in filters.items(): + field_obj = field_by_name[field_name] + if not field_obj.show_filter: + raise ProgramProjectFilterError( + { + "detail": ( + f"Поле '{field_name}' недоступно для фильтрации " + "(show_filter=False)." + ) + } + ) + + options = field_obj.get_options_list() + if not options: + raise ProgramProjectFilterError( + {"detail": f"Поле '{field_name}' не имеет вариантов (options)."} + ) + + invalid_values = [value for value in values if value not in options] + if invalid_values: + raise ProgramProjectFilterError( + { + "detail": f"Неверные значения для поля '{field_name}'.", + "invalid": invalid_values, + } + ) + + +def get_filtered_program_project_links( + *, + program: PartnerProgram, + filters: dict[str, list[str]], +): + validate_program_project_filters(program=program, filters=filters) + + qs = PartnerProgramProject.objects.filter(partner_program=program) + if not filters: + return qs.select_related("project").distinct() + + for field_name, values in filters.items(): + field = PartnerProgramField.objects.get( + partner_program=program, + name=field_name.strip(), + ) + field_value_exists = PartnerProgramFieldValue.objects.filter( + program_project=OuterRef("pk"), + field=field, + value_text__in=values, + ) + qs = qs.filter(Exists(field_value_exists)) + + return qs.select_related("project").distinct() diff --git a/partner_programs/services/publishing.py b/partner_programs/services/publishing.py new file mode 100644 index 00000000..f97fcfa9 --- /dev/null +++ b/partner_programs/services/publishing.py @@ -0,0 +1,35 @@ +from django.utils import timezone + +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from projects.models import Project + + +def publish_finished_program_projects(now=None) -> int: + if now is None: + now = timezone.now() + + program_ids = PartnerProgram.objects.filter( + publish_projects_after_finish=True, + datetime_finished__lte=now, + ).values_list("id", flat=True) + if not program_ids.exists(): + return 0 + + link_project_ids = PartnerProgramProject.objects.filter( + partner_program_id__in=program_ids + ).values_list("project_id", flat=True) + profile_project_ids = PartnerProgramUserProfile.objects.filter( + partner_program_id__in=program_ids, + project_id__isnull=False, + ).values_list("project_id", flat=True) + project_ids = link_project_ids.union(profile_project_ids) + + return Project.objects.filter( + id__in=project_ids, + is_public=False, + draft=False, + ).update(is_public=True) diff --git a/partner_programs/services/registration.py b/partner_programs/services/registration.py new file mode 100644 index 00000000..4d7be8d4 --- /dev/null +++ b/partner_programs/services/registration.py @@ -0,0 +1,107 @@ +from django.contrib.auth import get_user_model +from django.db import IntegrityError +from django.utils import timezone + +from partner_programs.helpers import date_to_iso +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from vacancy.mapping import MessageTypeEnum, UserProgramRegisterParams +from vacancy.tasks import send_email + +User = get_user_model() + +EXTERNAL_REGISTRATION_USER_FIELDS = ( + "first_name", + "last_name", + "patronymic", + "city", +) + + +class ProgramRegistrationError(Exception): + def __init__(self, detail: str): + self.detail = detail + super().__init__(detail) + + +def _send_program_registration_email(user, program: PartnerProgram) -> None: + send_email.delay( + UserProgramRegisterParams( + message_type=MessageTypeEnum.REGISTERED_PROGRAM_USER.value, + user_id=user.id, + program_name=program.name, + program_id=program.id, + schema_id=2, + ) + ) + + +def register_user_to_program( + *, + program: PartnerProgram, + user: User, + data, +) -> PartnerProgramUserProfile: + if program.datetime_registration_ends < timezone.now(): + raise ProgramRegistrationError("Registration period has ended.") + + try: + user_profile = PartnerProgramUserProfile.objects.create( + partner_program_data=data, + user=user, + partner_program=program, + ) + except IntegrityError: + raise ProgramRegistrationError("User already registered to this program.") + + _send_program_registration_email(user, program) + return user_profile + + +def create_user_and_register_to_program( + *, + program: PartnerProgram, + data, +) -> PartnerProgramUserProfile: + email = data.get("email") if data.get("email") else data.get("email_") + if not email: + raise ProgramRegistrationError("You need to pass an email address.") + + password = data.get("password") + if not password: + raise ProgramRegistrationError("You need to pass a password.") + + user, created = User.objects.get_or_create( + email=email, + defaults={ + "birthday": date_to_iso(data.get("birthday", "01-01-1900")), + "is_active": True, # bypass email verification for external forms + "onboarding_stage": None, # bypass onboarding for external forms + "verification_date": timezone.now(), # bypass manual verification + **{ + field_name: data.get(field_name, "") + for field_name in EXTERNAL_REGISTRATION_USER_FIELDS + }, + }, + ) + if created: + user.set_password(password) + user.save() + + user_profile_program_data = { + k: v + for k, v in data.items() + if k not in EXTERNAL_REGISTRATION_USER_FIELDS and k != "password" + } + try: + user_profile = PartnerProgramUserProfile.objects.create( + partner_program_data=user_profile_program_data, + user=user, + partner_program=program, + ) + except IntegrityError: + raise ProgramRegistrationError( + "User has already registered in this program." + ) + + _send_program_registration_email(user, program) + return user_profile diff --git a/partner_programs/tests/test_exports.py b/partner_programs/tests/test_exports.py new file mode 100644 index 00000000..31929772 --- /dev/null +++ b/partner_programs/tests/test_exports.py @@ -0,0 +1,173 @@ +import io + +from django.test import TestCase +from openpyxl import load_workbook +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramFieldValue +from partner_programs.services import ( + prepare_project_scores_export_data, + row_dict_for_link, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_field, + create_program_project, + create_project, + create_user, +) +from project_rates.models import Criteria, ProjectScore +from projects.models import Collaborator + + +class PartnerProgramProjectScoresExportTests(TestCase): + def setUp(self): + self.client = APIClient() + self.manager = create_user(prefix="program-rates-export-manager") + self.program = create_partner_program(name="Rates Export Program") + self.program.managers.add(self.manager) + + def test_manager_can_export_project_scores_to_xlsx(self): + field = create_program_field( + self.program, + name="track", + label="Track", + ) + project = create_project(name="Rated project") + program_project = create_program_project(self.program, project=project) + PartnerProgramFieldValue.objects.create( + program_project=program_project, + field=field, + value_text="ai", + ) + score_criteria = Criteria.objects.create( + partner_program=self.program, + name="Score", + type="str", + ) + comment_criteria = Criteria.objects.get( + partner_program=self.program, + name="Комментарий", + ) + expert = create_user(prefix="program-rates-expert", last_name="Ivanov") + second_expert = create_user( + prefix="program-rates-second-expert", + last_name="Petrov", + ) + ProjectScore.objects.create( + criteria=score_criteria, + user=expert, + project=project, + value="9", + ) + ProjectScore.objects.create( + criteria=comment_criteria, + user=expert, + project=project, + value="Strong project", + ) + ProjectScore.objects.create( + criteria=score_criteria, + user=second_expert, + project=project, + value="8", + ) + self.client.force_authenticate(self.manager) + + response = self.client.get(f"/programs/{self.program.id}/export-rates/") + + self.assertEqual(response.status_code, 200) + self.assertIn(".xlsx", response["Content-Disposition"]) + + workbook = load_workbook(io.BytesIO(response.content), read_only=True) + rows = list(workbook.active.iter_rows(values_only=True)) + workbook.close() + + header = list(rows[0]) + row_by_expert = { + dict(zip(header, row))["Фамилия эксперта"]: dict(zip(header, row)) + for row in rows[1:] + } + self.assertEqual(row_by_expert["Ivanov"]["Название проекта"], "Rated project") + self.assertEqual(row_by_expert["Ivanov"]["Track"], "ai") + self.assertEqual(row_by_expert["Ivanov"]["Score"], "9") + self.assertEqual(row_by_expert["Ivanov"]["Комментарий"], "Strong project") + self.assertEqual(row_by_expert["Petrov"]["Score"], "8") + self.assertIsNone(row_by_expert["Petrov"]["Комментарий"]) + + def test_scores_export_data_returns_empty_row_when_criteria_exist_without_scores(self): + Criteria.objects.create( + partner_program=self.program, + name="Score", + type="str", + ) + create_program_field( + self.program, + name="track", + label="Track", + ) + + export_data = prepare_project_scores_export_data(self.program.id) + + self.assertEqual(len(export_data), 1) + self.assertEqual(export_data[0]["Название проекта"], "") + self.assertEqual(export_data[0]["Фамилия эксперта"], "") + self.assertEqual(export_data[0]["Track"], "") + self.assertEqual(export_data[0]["Score"], "") + self.assertEqual(export_data[0]["Комментарий"], "") + + def test_non_manager_cannot_export_project_scores(self): + outsider = create_user(prefix="program-rates-export-outsider") + self.client.force_authenticate(outsider) + + response = self.client.get(f"/programs/{self.program.id}/export-rates/") + + self.assertEqual(response.status_code, 403) + + def test_row_dict_for_link_contains_team_and_program_field_values(self): + leader = create_user( + prefix="program-export-leader", + first_name="Leader", + last_name="Owner", + ) + member = create_user( + prefix="program-export-member", + first_name="Team", + last_name="Member", + ) + project = create_project( + leader=leader, + name="Team project", + description="Exported project", + region="Moscow", + presentation_address="https://example.com/presentation", + ) + Collaborator.objects.create(project=project, user=member, role="Developer") + program_project = create_program_project(self.program, project=project) + field = create_program_field( + self.program, + name="track", + label="Track", + ) + PartnerProgramFieldValue.objects.create( + program_project=program_project, + field=field, + value_text="ai", + ) + + row = row_dict_for_link( + program_project_link=program_project, + extra_field_keys_order=["name:track"], + row_number=1, + ) + + self.assertEqual(row["row_number"], 1) + self.assertEqual(row["project_name"], "Team project") + self.assertEqual(row["project_description"], "Exported project") + self.assertEqual(row["project_region"], "Moscow") + self.assertEqual(row["project_presentation"], "https://example.com/presentation") + self.assertGreaterEqual(row["team_size"], 2) + self.assertIn("Leader Owner", row["team_members"]) + self.assertIn("Team Member", row["team_members"]) + self.assertEqual(row["leader_full_name"], "Leader Owner") + self.assertEqual(row["name:track"], "ai") diff --git a/partner_programs/tests/test_permissions.py b/partner_programs/tests/test_permissions.py new file mode 100644 index 00000000..0fd35dee --- /dev/null +++ b/partner_programs/tests/test_permissions.py @@ -0,0 +1,71 @@ +from types import SimpleNamespace + +from django.contrib.auth.models import AnonymousUser +from django.test import TestCase + +from partner_programs.permissions import IsAdminOrManagerOfProgram, IsProjectLeader +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_project, + create_project, + create_user, +) + + +class PartnerProgramPermissionTests(TestCase): + def test_admin_or_manager_permission_allows_program_manager(self): + manager = create_user(prefix="program-permission-manager") + program = create_partner_program() + program.managers.add(manager) + request = SimpleNamespace(user=manager) + view = SimpleNamespace(kwargs={"pk": program.id}) + + self.assertTrue(IsAdminOrManagerOfProgram().has_permission(request, view)) + + def test_admin_or_manager_permission_allows_staff_user(self): + staff_user = create_user(prefix="program-permission-staff", is_staff=True) + request = SimpleNamespace(user=staff_user) + view = SimpleNamespace(kwargs={"pk": 999999}) + + self.assertTrue(IsAdminOrManagerOfProgram().has_permission(request, view)) + + def test_admin_or_manager_permission_rejects_outsider(self): + outsider = create_user(prefix="program-permission-outsider") + program = create_partner_program() + request = SimpleNamespace(user=outsider) + view = SimpleNamespace(kwargs={"pk": program.id}) + + self.assertFalse(IsAdminOrManagerOfProgram().has_permission(request, view)) + + def test_admin_or_manager_permission_rejects_anonymous_user(self): + request = SimpleNamespace(user=AnonymousUser()) + view = SimpleNamespace(kwargs={"pk": 1}) + + self.assertFalse(IsAdminOrManagerOfProgram().has_permission(request, view)) + + def test_project_leader_permission_allows_project_leader(self): + leader = create_user(prefix="program-permission-leader") + project = create_project(leader=leader) + program_project = create_program_project( + create_partner_program(), + project=project, + ) + request = SimpleNamespace(user=leader) + + self.assertTrue( + IsProjectLeader().has_object_permission(request, None, program_project) + ) + + def test_project_leader_permission_rejects_non_leader(self): + leader = create_user(prefix="program-permission-leader") + outsider = create_user(prefix="program-permission-not-leader") + project = create_project(leader=leader) + program_project = create_program_project( + create_partner_program(), + project=project, + ) + request = SimpleNamespace(user=outsider) + + self.assertFalse( + IsProjectLeader().has_object_permission(request, None, program_project) + ) diff --git a/partner_programs/tests/test_program_api.py b/partner_programs/tests/test_program_api.py index 8560cd75..9a07bd41 100644 --- a/partner_programs/tests/test_program_api.py +++ b/partner_programs/tests/test_program_api.py @@ -57,7 +57,7 @@ def setUp(self): self.user = create_user(prefix="program-register-user") self.program = create_partner_program() - @patch("partner_programs.services.send_email.delay") + @patch("partner_programs.services.registration.send_email.delay") def test_authenticated_user_can_register_to_program(self, send_email_delay): self.client.force_authenticate(self.user) @@ -75,7 +75,7 @@ def test_authenticated_user_can_register_to_program(self, send_email_delay): self.assertEqual(profile.partner_program_data, {"telegram": "@program_user"}) send_email_delay.assert_called_once() - @patch("partner_programs.services.send_email.delay") + @patch("partner_programs.services.registration.send_email.delay") def test_authenticated_user_cannot_register_twice(self, send_email_delay): create_program_member(self.program, user=self.user) self.client.force_authenticate(self.user) @@ -93,7 +93,7 @@ def test_authenticated_user_cannot_register_twice(self, send_email_delay): ) send_email_delay.assert_not_called() - @patch("partner_programs.services.send_email.delay") + @patch("partner_programs.services.registration.send_email.delay") def test_registration_is_blocked_after_deadline(self, send_email_delay): self.program.datetime_registration_ends = timezone.now() - timezone.timedelta( days=1 @@ -111,7 +111,7 @@ def test_registration_is_blocked_after_deadline(self, send_email_delay): self.assertEqual(response.data["detail"], "Registration period has ended.") send_email_delay.assert_not_called() - @patch("partner_programs.services.send_email.delay") + @patch("partner_programs.services.registration.send_email.delay") def test_external_registration_creates_user_and_program_profile( self, send_email_delay, @@ -138,7 +138,7 @@ def test_external_registration_creates_user_and_program_profile( self.assertEqual(profile.partner_program_data["telegram"], "@external") send_email_delay.assert_called_once() - @patch("partner_programs.services.send_email.delay") + @patch("partner_programs.services.registration.send_email.delay") def test_external_registration_accepts_email_compatibility_field( self, send_email_delay, @@ -168,7 +168,7 @@ def test_external_registration_accepts_email_compatibility_field( ) send_email_delay.assert_called_once() - @patch("partner_programs.services.send_email.delay") + @patch("partner_programs.services.registration.send_email.delay") def test_external_registration_test_ping_does_not_create_profile( self, send_email_delay, @@ -183,7 +183,7 @@ def test_external_registration_test_ping_does_not_create_profile( self.assertFalse(PartnerProgramUserProfile.objects.exists()) send_email_delay.assert_not_called() - @patch("partner_programs.services.send_email.delay") + @patch("partner_programs.services.registration.send_email.delay") def test_external_registration_requires_email(self, send_email_delay): response = self.client.post( f"/programs/{self.program.id}/register_new/", @@ -200,7 +200,7 @@ def test_external_registration_requires_email(self, send_email_delay): self.assertFalse(PartnerProgramUserProfile.objects.exists()) send_email_delay.assert_not_called() - @patch("partner_programs.services.send_email.delay") + @patch("partner_programs.services.registration.send_email.delay") def test_external_registration_requires_password(self, send_email_delay): response = self.client.post( f"/programs/{self.program.id}/register_new/", @@ -217,7 +217,7 @@ def test_external_registration_requires_password(self, send_email_delay): self.assertFalse(PartnerProgramUserProfile.objects.exists()) send_email_delay.assert_not_called() - @patch("partner_programs.services.send_email.delay") + @patch("partner_programs.services.registration.send_email.delay") def test_external_registration_rejects_duplicate_program_profile( self, send_email_delay, From 9ed1c8946dac82bb9c2a515968e797c59c56665f Mon Sep 17 00:00:00 2001 From: Toksi Date: Thu, 28 May 2026 10:57:36 +0500 Subject: [PATCH 19/32] =?UTF-8?q?=D0=9D=D0=B0=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D1=80=D0=B0=D1=81=D1=88?= =?UTF-8?q?=D0=B8=D1=80=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=20?= =?UTF-8?q?project=5Frates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/project-rates.md | 170 +++++++++- project_rates/tests.py | 301 ------------------ project_rates/tests/__init__.py | 0 project_rates/tests/helpers.py | 91 ++++++ .../tests/test_distributed_evaluation.py | 126 ++++++++ .../tests/test_expert_assignments.py | 85 +++++ project_rates/tests/test_project_list_api.py | 107 +++++++ project_rates/tests/test_rate_project_api.py | 221 +++++++++++++ project_rates/tests/test_signals.py | 17 + 9 files changed, 816 insertions(+), 302 deletions(-) delete mode 100644 project_rates/tests.py create mode 100644 project_rates/tests/__init__.py create mode 100644 project_rates/tests/helpers.py create mode 100644 project_rates/tests/test_distributed_evaluation.py create mode 100644 project_rates/tests/test_expert_assignments.py create mode 100644 project_rates/tests/test_project_list_api.py create mode 100644 project_rates/tests/test_rate_project_api.py create mode 100644 project_rates/tests/test_signals.py diff --git a/docs/modules/project-rates.md b/docs/modules/project-rates.md index 519541ea..88aed89d 100644 --- a/docs/modules/project-rates.md +++ b/docs/modules/project-rates.md @@ -1,3 +1,171 @@ # Project Rates -TODO +## Назначение + +Модуль `project_rates` отвечает за экспертную оценку проектов внутри +партнерских программ. + +Он используется, когда проект уже привязан к программе и эксперты должны +выставить оценки по критериям программы. Результаты оценок затем используются в +интерфейсе оценки и в Excel-выгрузках партнерской программы. + +## Статус модуля + +Модуль рабочий и используется вместе с `partner_programs`. Базовые сценарии +зафиксированы regression-тестами, но бизнес-логика оценки пока находится во +`views.py`. Перед крупным рефакторингом важно сохранять текущий API-контракт и +правила доступа экспертов. + +## Основные возможности + +- хранение критериев оценки программы; +- выставление и обновление оценок проекта экспертом; +- проверка типов и диапазонов значений; +- ограничение количества экспертов, которые могут оценить один проект; +- режим распределенной оценки, когда эксперт видит и оценивает только + назначенные ему проекты; +- назначение проектов экспертам через Django admin; +- список проектов программы для оценки; +- фильтрация списка проектов по дополнительным полям программы; +- передача оценок в выгрузку результатов программы. + +## Архитектура + +- `project_rates/models.py` - критерии, оценки и назначения проектов экспертам. +- `project_rates/views.py` - API оценки проекта и списка проектов для оценки. +- `project_rates/serializers.py` - request serializer оценки и response + serializer списка проектов. +- `project_rates/validators.py` - проверка типа значения и числовых границ + оценки. +- `project_rates/signals.py` - создание дефолтного критерия `Комментарий` для + новой программы. +- `project_rates/admin.py` - управление критериями, оценками и bulk-назначением + проектов экспертам. +- `project_rates/tests/` - regression-тесты API, модели назначений, + сериализации и критических правил. + +## Ключевые сущности + +- `Criteria` - критерий оценки проекта. Принадлежит конкретной партнерской + программе, имеет тип `str`, `int`, `float` или `bool`, а для числовых типов + может иметь `min_value` и `max_value`. +- `ProjectScore` - оценка проекта конкретным экспертом по конкретному критерию. + Уникальна по связке `criteria`, `user`, `project`. +- `ProjectExpertAssignment` - назначение проекта эксперту в программе. Нужно + для режима распределенной оценки. + +## API + +- `GET /rate-project/` - список проектов программы для оценки. +- `POST /rate-project/` - список проектов программы с фильтрами в + JSON body. +- `POST /rate-project/rate/` - выставление или обновление оценок + проекта. + +## Основные сценарии + +### 1. Эксперт открывает список проектов для оценки + +Эксперт запрашивает `GET /rate-project/`. + +API возвращает только проекты, привязанные к программе через +`PartnerProgramProject` и не находящиеся в черновике. + +Если у проекта уже есть оценки текущего эксперта, поле `criterias` возвращает +выставленные значения. Если текущий эксперт еще не оценивал проект, поле +`criterias` возвращает список критериев программы. + +### 2. Эксперт фильтрует проекты программы + +Эксперт может отправить `POST /rate-project/` с телом: + +```json +{ + "filters": { + "track": ["FinTech"] + } +} +``` + +Фильтры применяются к дополнительным полям программы, которые хранятся в +`PartnerProgramFieldValue`. + +### 3. Эксперт выставляет оценки + +Эксперт отправляет `POST /rate-project/rate/` со списком значений: + +```json +[ + { + "criterion_id": 1, + "value": "8" + } +] +``` + +API проверяет, что: + +- пользователь является экспертом; +- эксперт состоит в программе, к которой относятся критерии; +- все критерии в запросе относятся к одной программе; +- проект привязан к этой программе; +- значение соответствует типу критерия и числовым ограничениям; +- лимит `max_project_rates` не превышен. + +При повторной отправке оценки того же эксперта по тому же критерию значение +обновляется. + +### 4. Распределенная оценка + +Если у программы включено `is_distributed_evaluation`, эксперт видит и может +оценивать только проекты, назначенные ему через `ProjectExpertAssignment`. + +Назначение валидируется при сохранении: + +- эксперт должен состоять в программе; +- проект должен быть привязан к программе; +- количество назначений по проекту не должно превышать `max_project_rates`. + +Назначение нельзя удалить, если эксперт уже выставил оценку по этому проекту. + +### 5. Выгрузка результатов + +Модуль `partner_programs` использует `Criteria` и `ProjectScore` для подготовки +Excel-выгрузки оценок программы через `/programs//export-rates/`. + +## Связи с другими модулями + +- `partner_programs` - программа, связь проекта с программой, дополнительные + поля и выгрузка результатов. +- `projects` - оцениваемые проекты. +- `users` - эксперты программы и лидеры проектов. +- `vacancy.tasks.send_email` - уведомление лидера проекта после оценки. + +## Ограничения и правила + +- Значения оценок хранятся строкой, а тип проверяется через критерий. +- `ProjectScore.objects.bulk_create(..., update_conflicts=True)` обновляет + существующую оценку без создания дубля. +- `max_project_rates` ограничивает число разных экспертов, которые могут + оценить один проект в программе; текущий эксперт может обновить свою оценку. +- Дефолтный критерий `Комментарий` создается сигналом при создании программы. +- Основная бизнес-логика оценки пока находится во `project_rates/views.py`. + +## Тесты + +Текущие regression-тесты проверяют: + +- обычную оценку проекта экспертом программы; +- обновление существующей оценки без дублей; +- запрет оценки экспертом, который не состоит в программе; +- запрет оценки проекта, который не привязан к программе; +- запрет оценки критериями из разных программ; +- ограничение `max_project_rates`; +- валидацию числовых границ и boolean-значений; +- список проектов программы без распределенной оценки; +- список проектов программы при распределенной оценке; +- фильтрацию проектов по дополнительным полям программы; +- response-поля `scored`, `rated_experts`, `rated_count`, `max_rates`; +- создание дефолтного критерия `Комментарий`; +- валидацию `ProjectExpertAssignment`; +- запрет удаления назначения после выставленной оценки. diff --git a/project_rates/tests.py b/project_rates/tests.py deleted file mode 100644 index 59e7ea12..00000000 --- a/project_rates/tests.py +++ /dev/null @@ -1,301 +0,0 @@ -from unittest.mock import patch - -from django.core.exceptions import ValidationError -from django.test import TestCase -from django.utils import timezone - -from rest_framework.test import APIClient - -from partner_programs.models import PartnerProgram, PartnerProgramProject -from projects.models import Project -from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore -from users.models import CustomUser - - -class DistributedEvaluationAPITests(TestCase): - def setUp(self): - self.client = APIClient() - now = timezone.now() - - self.expert_user = CustomUser.objects.create_user( - email="expert@example.com", - password="pass", - first_name="Expert", - last_name="User", - birthday="1990-01-01", - user_type=CustomUser.EXPERT, - is_active=True, - ) - self.other_expert_user = CustomUser.objects.create_user( - email="expert2@example.com", - password="pass", - first_name="Second", - last_name="Expert", - birthday="1991-01-01", - user_type=CustomUser.EXPERT, - is_active=True, - ) - self.leader = CustomUser.objects.create_user( - email="leader@example.com", - password="pass", - first_name="Leader", - last_name="User", - birthday="1992-01-01", - user_type=CustomUser.MEMBER, - is_active=True, - ) - - self.program = PartnerProgram.objects.create( - name="Program", - tag="program_tag", - description="Program description", - city="Moscow", - data_schema={}, - draft=False, - projects_availability="all_users", - datetime_registration_ends=now + timezone.timedelta(days=10), - datetime_started=now - timezone.timedelta(days=1), - datetime_finished=now + timezone.timedelta(days=30), - max_project_rates=2, - ) - self.expert_user.expert.programs.add(self.program) - self.other_expert_user.expert.programs.add(self.program) - - self.project_1 = Project.objects.create( - leader=self.leader, - draft=False, - is_public=False, - name="Project 1", - ) - self.project_2 = Project.objects.create( - leader=self.leader, - draft=False, - is_public=False, - name="Project 2", - ) - PartnerProgramProject.objects.create( - partner_program=self.program, - project=self.project_1, - ) - PartnerProgramProject.objects.create( - partner_program=self.program, - project=self.project_2, - ) - - self.criteria = Criteria.objects.create( - name="Impact", - type="int", - min_value=0, - max_value=10, - partner_program=self.program, - ) - - def _projects_url(self) -> str: - return f"/rate-project/{self.program.id}" - - def _rate_url(self, project_id: int) -> str: - return f"/rate-project/rate/{project_id}" - - def test_list_projects_without_distribution_returns_all_program_projects(self): - self.client.force_authenticate(self.expert_user) - - response = self.client.get(self._projects_url()) - - self.assertEqual(response.status_code, 200) - returned_ids = {item["id"] for item in response.data["results"]} - self.assertSetEqual(returned_ids, {self.project_1.id, self.project_2.id}) - - def test_list_projects_with_distribution_returns_only_assigned_projects(self): - self.program.is_distributed_evaluation = True - self.program.save(update_fields=["is_distributed_evaluation"]) - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project_1, - expert=self.expert_user.expert, - ) - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project_2, - expert=self.other_expert_user.expert, - ) - - self.client.force_authenticate(self.expert_user) - response = self.client.get(self._projects_url()) - - self.assertEqual(response.status_code, 200) - returned_ids = [item["id"] for item in response.data["results"]] - self.assertListEqual(returned_ids, [self.project_1.id]) - - @patch("project_rates.views.send_email.delay") - def test_rate_project_with_distribution_rejects_unassigned_expert(self, _mock_delay): - self.program.is_distributed_evaluation = True - self.program.save(update_fields=["is_distributed_evaluation"]) - - self.client.force_authenticate(self.expert_user) - response = self.client.post( - self._rate_url(self.project_1.id), - [{"criterion_id": self.criteria.id, "value": "8"}], - format="json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual( - response.data["error"], "you are not assigned to rate this project" - ) - self.assertFalse(ProjectScore.objects.filter(project=self.project_1).exists()) - - @patch("project_rates.views.send_email.delay") - def test_rate_project_with_distribution_accepts_assigned_expert(self, mock_delay): - self.program.is_distributed_evaluation = True - self.program.save(update_fields=["is_distributed_evaluation"]) - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project_1, - expert=self.expert_user.expert, - ) - - self.client.force_authenticate(self.expert_user) - response = self.client.post( - self._rate_url(self.project_1.id), - [{"criterion_id": self.criteria.id, "value": "8"}], - format="json", - ) - - self.assertEqual(response.status_code, 201) - self.assertTrue( - ProjectScore.objects.filter( - project=self.project_1, - user=self.expert_user, - criteria=self.criteria, - value="8", - ).exists() - ) - mock_delay.assert_called_once() - - -class ProjectExpertAssignmentModelTests(TestCase): - def setUp(self): - now = timezone.now() - self.program = PartnerProgram.objects.create( - name="Program", - tag="program_tag", - description="Program description", - city="Moscow", - data_schema={}, - draft=False, - projects_availability="all_users", - datetime_registration_ends=now + timezone.timedelta(days=10), - datetime_started=now - timezone.timedelta(days=1), - datetime_finished=now + timezone.timedelta(days=30), - max_project_rates=1, - ) - - self.leader = CustomUser.objects.create_user( - email="leader2@example.com", - password="pass", - first_name="Leader", - last_name="Two", - birthday="1993-01-01", - user_type=CustomUser.MEMBER, - is_active=True, - ) - self.project = Project.objects.create( - leader=self.leader, - draft=False, - is_public=False, - name="Project", - ) - PartnerProgramProject.objects.create( - partner_program=self.program, - project=self.project, - ) - - self.expert_1_user = CustomUser.objects.create_user( - email="model-expert-1@example.com", - password="pass", - first_name="Model", - last_name="Expert1", - birthday="1990-02-01", - user_type=CustomUser.EXPERT, - is_active=True, - ) - self.expert_2_user = CustomUser.objects.create_user( - email="model-expert-2@example.com", - password="pass", - first_name="Model", - last_name="Expert2", - birthday="1990-03-01", - user_type=CustomUser.EXPERT, - is_active=True, - ) - self.expert_1_user.expert.programs.add(self.program) - self.expert_2_user.expert.programs.add(self.program) - - def test_assignment_requires_expert_in_program(self): - self.expert_1_user.expert.programs.remove(self.program) - - with self.assertRaises(ValidationError): - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project, - expert=self.expert_1_user.expert, - ) - - def test_assignment_requires_project_link_to_program(self): - other_program = PartnerProgram.objects.create( - name="Other Program", - tag="other_program_tag", - description="Program description", - city="Moscow", - data_schema={}, - draft=False, - projects_availability="all_users", - datetime_registration_ends=timezone.now() + timezone.timedelta(days=10), - datetime_started=timezone.now() - timezone.timedelta(days=1), - datetime_finished=timezone.now() + timezone.timedelta(days=30), - ) - self.expert_1_user.expert.programs.add(other_program) - - with self.assertRaises(ValidationError): - ProjectExpertAssignment.objects.create( - partner_program=other_program, - project=self.project, - expert=self.expert_1_user.expert, - ) - - def test_assignment_respects_max_project_rates_limit(self): - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project, - expert=self.expert_1_user.expert, - ) - - with self.assertRaises(ValidationError): - ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project, - expert=self.expert_2_user.expert, - ) - - def test_assignment_cannot_be_deleted_after_scoring(self): - assignment = ProjectExpertAssignment.objects.create( - partner_program=self.program, - project=self.project, - expert=self.expert_1_user.expert, - ) - criteria = Criteria.objects.create( - name="Impact", - type="int", - min_value=0, - max_value=10, - partner_program=self.program, - ) - ProjectScore.objects.create( - criteria=criteria, - user=self.expert_1_user, - project=self.project, - value="7", - ) - - with self.assertRaises(ValidationError): - assignment.delete() diff --git a/project_rates/tests/__init__.py b/project_rates/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/project_rates/tests/helpers.py b/project_rates/tests/helpers.py new file mode 100644 index 00000000..9825baaf --- /dev/null +++ b/project_rates/tests/helpers.py @@ -0,0 +1,91 @@ +from itertools import count + +from django.utils import timezone + +from partner_programs.models import PartnerProgram, PartnerProgramProject +from projects.models import Project +from project_rates.models import Criteria +from users.models import CustomUser + +_counter = count(1) + + +def create_rate_user( + *, + prefix: str = "rate-user", + user_type: int = CustomUser.MEMBER, +): + index = next(_counter) + return CustomUser.objects.create_user( + email=f"{prefix}-{index}@example.com", + password="pass", + first_name="Rate", + last_name="User", + birthday="1990-01-01", + user_type=user_type, + is_active=True, + ) + + +def create_rate_expert(*, prefix: str = "rate-expert", program=None): + user = create_rate_user(prefix=prefix, user_type=CustomUser.EXPERT) + if program is not None: + user.expert.programs.add(program) + return user + + +def create_rate_program(**overrides): + index = next(_counter) + now = timezone.now() + defaults = { + "name": f"Rate Program {index}", + "tag": f"rate-program-{index}", + "description": "Program description", + "city": "Moscow", + "data_schema": {}, + "draft": False, + "projects_availability": "all_users", + "datetime_registration_ends": now + timezone.timedelta(days=10), + "datetime_started": now - timezone.timedelta(days=1), + "datetime_finished": now + timezone.timedelta(days=30), + "max_project_rates": 2, + } + defaults.update(overrides) + return PartnerProgram.objects.create(**defaults) + + +def create_rate_project(*, leader=None, name: str = "Rate Project", **overrides): + index = next(_counter) + defaults = { + "leader": leader or create_rate_user(prefix="rate-leader"), + "draft": False, + "is_public": False, + "name": f"{name} {index}", + } + defaults.update(overrides) + return Project.objects.create(**defaults) + + +def link_project_to_program(program, project): + return PartnerProgramProject.objects.create( + partner_program=program, + project=project, + ) + + +def create_rate_criteria( + program, + *, + name: str = "Impact", + type: str = "int", + min_value=None, + max_value=None, +): + index = next(_counter) + return Criteria.objects.create( + name=f"{name} {index}", + type=type, + min_value=min_value, + max_value=max_value, + partner_program=program, + ) diff --git a/project_rates/tests/test_distributed_evaluation.py b/project_rates/tests/test_distributed_evaluation.py new file mode 100644 index 00000000..8de2c3ee --- /dev/null +++ b/project_rates/tests/test_distributed_evaluation.py @@ -0,0 +1,126 @@ +from unittest.mock import patch + +from django.test import TestCase + +from rest_framework.test import APIClient + +from project_rates.models import ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import ( + create_rate_criteria, + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) + + +class DistributedEvaluationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.program = create_rate_program(max_project_rates=2) + self.expert_user = create_rate_expert(prefix="distributed-expert") + self.other_expert_user = create_rate_expert(prefix="distributed-other-expert") + self.expert_user.expert.programs.add(self.program) + self.other_expert_user.expert.programs.add(self.program) + + self.leader = create_rate_user(prefix="distributed-leader") + self.project_1 = create_rate_project( + leader=self.leader, + name="Distributed Project 1", + ) + self.project_2 = create_rate_project( + leader=self.leader, + name="Distributed Project 2", + ) + link_project_to_program(self.program, self.project_1) + link_project_to_program(self.program, self.project_2) + + self.criteria = create_rate_criteria( + self.program, + name="Impact", + min_value=0, + max_value=10, + ) + + def _projects_url(self) -> str: + return f"/rate-project/{self.program.id}" + + def _rate_url(self, project_id: int) -> str: + return f"/rate-project/rate/{project_id}" + + def test_list_projects_without_distribution_returns_all_program_projects(self): + self.client.force_authenticate(self.expert_user) + + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertSetEqual(returned_ids, {self.project_1.id, self.project_2.id}) + + def test_list_projects_with_distribution_returns_only_assigned_projects(self): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_1, + expert=self.expert_user.expert, + ) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_2, + expert=self.other_expert_user.expert, + ) + + self.client.force_authenticate(self.expert_user) + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + returned_ids = [item["id"] for item in response.data["results"]] + self.assertListEqual(returned_ids, [self.project_1.id]) + + @patch("project_rates.views.send_email.delay") + def test_rate_project_with_distribution_rejects_unassigned_expert(self, _mock_delay): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + + self.client.force_authenticate(self.expert_user) + response = self.client.post( + self._rate_url(self.project_1.id), + [{"criterion_id": self.criteria.id, "value": "8"}], + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["error"], "you are not assigned to rate this project" + ) + self.assertFalse(ProjectScore.objects.filter(project=self.project_1).exists()) + + @patch("project_rates.views.send_email.delay") + def test_rate_project_with_distribution_accepts_assigned_expert(self, mock_delay): + self.program.is_distributed_evaluation = True + self.program.save(update_fields=["is_distributed_evaluation"]) + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project_1, + expert=self.expert_user.expert, + ) + + self.client.force_authenticate(self.expert_user) + response = self.client.post( + self._rate_url(self.project_1.id), + [{"criterion_id": self.criteria.id, "value": "8"}], + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertTrue( + ProjectScore.objects.filter( + project=self.project_1, + user=self.expert_user, + criteria=self.criteria, + value="8", + ).exists() + ) + mock_delay.assert_called_once() diff --git a/project_rates/tests/test_expert_assignments.py b/project_rates/tests/test_expert_assignments.py new file mode 100644 index 00000000..f054ca84 --- /dev/null +++ b/project_rates/tests/test_expert_assignments.py @@ -0,0 +1,85 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import ( + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) + + +class ProjectExpertAssignmentModelTests(TestCase): + def setUp(self): + self.program = create_rate_program(max_project_rates=1) + self.leader = create_rate_user(prefix="assignment-leader") + self.project = create_rate_project( + leader=self.leader, + name="Assignment Project", + ) + link_project_to_program(self.program, self.project) + + self.expert_1_user = create_rate_expert(prefix="assignment-expert-1") + self.expert_2_user = create_rate_expert(prefix="assignment-expert-2") + self.expert_1_user.expert.programs.add(self.program) + self.expert_2_user.expert.programs.add(self.program) + + def test_assignment_requires_expert_in_program(self): + self.expert_1_user.expert.programs.remove(self.program) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + def test_assignment_requires_project_link_to_program(self): + other_program = create_rate_program(name="Other Assignment Program") + self.expert_1_user.expert.programs.add(other_program) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=other_program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + def test_assignment_respects_max_project_rates_limit(self): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + + with self.assertRaises(ValidationError): + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_2_user.expert, + ) + + def test_assignment_cannot_be_deleted_after_scoring(self): + assignment = ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=self.project, + expert=self.expert_1_user.expert, + ) + criteria = Criteria.objects.create( + name="Impact", + type="int", + min_value=0, + max_value=10, + partner_program=self.program, + ) + ProjectScore.objects.create( + criteria=criteria, + user=self.expert_1_user, + project=self.project, + value="7", + ) + + with self.assertRaises(ValidationError): + assignment.delete() diff --git a/project_rates/tests/test_project_list_api.py b/project_rates/tests/test_project_list_api.py new file mode 100644 index 00000000..9a90d2bf --- /dev/null +++ b/project_rates/tests/test_project_list_api.py @@ -0,0 +1,107 @@ +from django.test import TestCase + +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramField, PartnerProgramFieldValue +from project_rates.models import ProjectScore +from project_rates.tests.helpers import ( + create_rate_criteria, + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) + + +class ProjectListForRateAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.program = create_rate_program(max_project_rates=3) + self.expert = create_rate_expert(program=self.program) + self.leader = create_rate_user(prefix="rate-list-leader") + self.project_1 = create_rate_project( + leader=self.leader, + name="Filtered Project", + ) + self.project_2 = create_rate_project( + leader=self.leader, + name="Other Project", + ) + self.link_1 = link_project_to_program(self.program, self.project_1) + self.link_2 = link_project_to_program(self.program, self.project_2) + self.criteria = create_rate_criteria( + self.program, + min_value=0, + max_value=10, + ) + + def _projects_url(self) -> str: + return f"/rate-project/{self.program.id}" + + def test_post_filters_projects_by_program_field_values(self): + field = PartnerProgramField.objects.create( + partner_program=self.program, + name="track", + label="Track", + field_type="select", + show_filter=True, + options="FinTech|EdTech", + ) + PartnerProgramFieldValue.objects.create( + program_project=self.link_1, + field=field, + value_text="FinTech", + ) + PartnerProgramFieldValue.objects.create( + program_project=self.link_2, + field=field, + value_text="EdTech", + ) + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._projects_url(), + {"filters": {"track": ["FinTech"]}}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + returned_ids = [item["id"] for item in response.data["results"]] + self.assertEqual(returned_ids, [self.project_1.id]) + + def test_list_marks_current_expert_score_and_program_rate_limits(self): + ProjectScore.objects.create( + criteria=self.criteria, + user=self.expert, + project=self.project_1, + value="9", + ) + self.client.force_authenticate(self.expert) + + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + project_data = next( + item for item in response.data["results"] if item["id"] == self.project_1.id + ) + self.assertTrue(project_data["scored"]) + self.assertEqual(project_data["rated_experts"], [self.expert.id]) + self.assertEqual(project_data["rated_count"], 1) + self.assertEqual(project_data["max_rates"], 3) + self.assertEqual(project_data["criterias"][0]["value"], "9") + + def test_list_returns_criterias_when_current_expert_has_no_scores(self): + self.client.force_authenticate(self.expert) + + response = self.client.get(self._projects_url()) + + self.assertEqual(response.status_code, 200) + project_data = next( + item for item in response.data["results"] if item["id"] == self.project_1.id + ) + self.assertFalse(project_data["scored"]) + self.assertEqual(project_data["rated_experts"], []) + self.assertEqual(project_data["rated_count"], 0) + criteria_ids = {item["id"] for item in project_data["criterias"]} + self.assertIn(self.criteria.id, criteria_ids) diff --git a/project_rates/tests/test_rate_project_api.py b/project_rates/tests/test_rate_project_api.py new file mode 100644 index 00000000..6532a329 --- /dev/null +++ b/project_rates/tests/test_rate_project_api.py @@ -0,0 +1,221 @@ +from unittest.mock import patch + +from django.test import TestCase + +from rest_framework.test import APIClient + +from project_rates.models import ProjectScore +from project_rates.tests.helpers import ( + create_rate_criteria, + create_rate_expert, + create_rate_program, + create_rate_project, + create_rate_user, + link_project_to_program, +) + + +class RateProjectAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.program = create_rate_program(max_project_rates=2) + self.expert = create_rate_expert(program=self.program) + self.other_expert = create_rate_expert( + prefix="rate-other-expert", + program=self.program, + ) + self.leader = create_rate_user(prefix="rate-leader") + self.project = create_rate_project(leader=self.leader) + link_project_to_program(self.program, self.project) + self.criteria = create_rate_criteria( + self.program, + min_value=0, + max_value=10, + ) + + def _rate_url(self, project_id: int | None = None) -> str: + return f"/rate-project/rate/{project_id or self.project.id}" + + def _payload(self, criteria=None, value: str = "8") -> list[dict]: + return [{"criterion_id": (criteria or self.criteria).id, "value": value}] + + @patch("project_rates.views.send_email.delay") + def test_expert_can_rate_project_without_distribution(self, send_email_delay): + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(), + self._payload(value="8"), + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data, {"success": True}) + self.assertTrue( + ProjectScore.objects.filter( + criteria=self.criteria, + user=self.expert, + project=self.project, + value="8", + ).exists() + ) + send_email_delay.assert_called_once() + + @patch("project_rates.views.send_email.delay") + def test_expert_can_update_existing_score(self, send_email_delay): + ProjectScore.objects.create( + criteria=self.criteria, + user=self.expert, + project=self.project, + value="6", + ) + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(), + self._payload(value="9"), + format="json", + ) + + self.assertEqual(response.status_code, 201) + scores = ProjectScore.objects.filter( + criteria=self.criteria, + user=self.expert, + project=self.project, + ) + self.assertEqual(scores.count(), 1) + self.assertEqual(scores.get().value, "9") + send_email_delay.assert_called_once() + + @patch("project_rates.views.send_email.delay") + def test_rate_project_rejects_expert_without_program_membership( + self, + send_email_delay, + ): + outsider = create_rate_expert(prefix="rate-outsider") + self.client.force_authenticate(outsider) + + response = self.client.post( + self._rate_url(), + self._payload(value="8"), + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.data["error"], + "you have no permission to rate this program", + ) + self.assertFalse(ProjectScore.objects.filter(user=outsider).exists()) + send_email_delay.assert_not_called() + + @patch("project_rates.views.send_email.delay") + def test_rate_project_rejects_project_not_linked_to_program( + self, + send_email_delay, + ): + unlinked_project = create_rate_project( + leader=self.leader, + name="Unlinked", + ) + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(unlinked_project.id), + self._payload(value="8"), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["error"], "Project is not linked to the program") + self.assertFalse(ProjectScore.objects.filter(project=unlinked_project).exists()) + send_email_delay.assert_not_called() + + @patch("project_rates.views.send_email.delay") + def test_rate_project_rejects_criteria_from_different_programs( + self, + send_email_delay, + ): + other_program = create_rate_program(name="Other Rate Program") + other_criteria = create_rate_criteria(other_program, min_value=0, max_value=10) + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(), + [ + {"criterion_id": self.criteria.id, "value": "8"}, + {"criterion_id": other_criteria.id, "value": "7"}, + ], + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data["error"], + "All criteria must belong to the same program", + ) + self.assertFalse(ProjectScore.objects.filter(project=self.project).exists()) + send_email_delay.assert_not_called() + + @patch("project_rates.views.send_email.delay") + def test_rate_project_respects_max_project_rates_for_new_expert( + self, + send_email_delay, + ): + self.program.max_project_rates = 1 + self.program.save(update_fields=["max_project_rates"]) + ProjectScore.objects.create( + criteria=self.criteria, + user=self.expert, + project=self.project, + value="8", + ) + self.client.force_authenticate(self.other_expert) + + response = self.client.post( + self._rate_url(), + self._payload(value="7"), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.data, + { + "error": "max project rates reached for this program", + "max_project_rates": 1, + }, + ) + self.assertFalse(ProjectScore.objects.filter(user=self.other_expert).exists()) + send_email_delay.assert_not_called() + + @patch("project_rates.views.send_email.delay") + def test_rate_project_validates_numeric_limits(self, send_email_delay): + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(), + self._payload(value="11"), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("Оценка этого критерия превысила", response.data["error"]) + self.assertFalse(ProjectScore.objects.filter(project=self.project).exists()) + send_email_delay.assert_not_called() + + @patch("project_rates.views.send_email.delay") + def test_rate_project_validates_bool_value(self, send_email_delay): + bool_criteria = create_rate_criteria(self.program, type="bool") + self.client.force_authenticate(self.expert) + + response = self.client.post( + self._rate_url(), + self._payload(criteria=bool_criteria, value="yes"), + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("не соответствует формату", response.data["error"]) + self.assertFalse(ProjectScore.objects.filter(criteria=bool_criteria).exists()) + send_email_delay.assert_not_called() diff --git a/project_rates/tests/test_signals.py b/project_rates/tests/test_signals.py new file mode 100644 index 00000000..f3c2b539 --- /dev/null +++ b/project_rates/tests/test_signals.py @@ -0,0 +1,17 @@ +from django.test import TestCase + +from project_rates.models import Criteria +from project_rates.tests.helpers import create_rate_program + + +class CriteriaSignalTests(TestCase): + def test_program_creation_creates_default_comment_criteria(self): + program = create_rate_program(name="Signal Program") + + self.assertTrue( + Criteria.objects.filter( + partner_program=program, + name="Комментарий", + type="str", + ).exists() + ) From 7012cae74460bf088ca96f4044a0507e0c0b2181 Mon Sep 17 00:00:00 2001 From: Toksi Date: Thu, 28 May 2026 10:58:41 +0500 Subject: [PATCH 20/32] =?UTF-8?q?=D0=91=D0=B8=D0=B7=D0=BD=D0=B5=D1=81-?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20project=5Frates=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/project-rates.md | 11 +- project_rates/services.py | 175 ++++++++++++++++ .../tests/test_distributed_evaluation.py | 4 +- project_rates/tests/test_rate_project_api.py | 16 +- project_rates/views.py | 191 +++--------------- 5 files changed, 224 insertions(+), 173 deletions(-) create mode 100644 project_rates/services.py diff --git a/docs/modules/project-rates.md b/docs/modules/project-rates.md index 88aed89d..5b5e2fbc 100644 --- a/docs/modules/project-rates.md +++ b/docs/modules/project-rates.md @@ -12,9 +12,9 @@ ## Статус модуля Модуль рабочий и используется вместе с `partner_programs`. Базовые сценарии -зафиксированы regression-тестами, но бизнес-логика оценки пока находится во -`views.py`. Перед крупным рефакторингом важно сохранять текущий API-контракт и -правила доступа экспертов. +зафиксированы regression-тестами, бизнес-логика оценки вынесена в service +layer. При дальнейших изменениях важно сохранять текущий API-контракт и правила +доступа экспертов. ## Основные возможности @@ -33,6 +33,8 @@ - `project_rates/models.py` - критерии, оценки и назначения проектов экспертам. - `project_rates/views.py` - API оценки проекта и списка проектов для оценки. +- `project_rates/services.py` - бизнес-логика выставления оценок, фильтрации + проектов для оценки и проверки лимитов. - `project_rates/serializers.py` - request serializer оценки и response serializer списка проектов. - `project_rates/validators.py` - проверка типа значения и числовых границ @@ -149,7 +151,8 @@ Excel-выгрузки оценок программы через `/programs/ PartnerProgram: + return PartnerProgram.objects.get(pk=program_id) + + +def extract_project_rate_filters(method: str, data) -> dict: + """ + Accept filters from JSON body to mirror /partner_programs//projects/filter/: + {"filters": {"case": ["Кейс 1"]}} + """ + if method != "POST": + return {} + + body_filters = data.get("filters") if isinstance(data, dict) else {} + return body_filters if isinstance(body_filters, dict) else {} + + +def get_projects_for_rate_queryset( + *, + program: PartnerProgram, + user, + field_filters: dict, +) -> QuerySet[Project]: + filters_serializer = ProgramProjectFilterRequestSerializer( + data={"filters": field_filters} + ) + filters_serializer.is_valid(raise_exception=True) + validated_filters = filters_serializer.validated_data.get("filters", {}) + + try: + program_projects_qs = filter_program_projects_by_field_name( + program, validated_filters + ) + except ValueError as e: + raise ValidationError({"filters": str(e)}) + + project_ids = program_projects_qs.values_list("project_id", flat=True) + + scores_prefetch = Prefetch( + "scores", + queryset=ProjectScore.objects.filter( + criteria__partner_program=program + ).select_related("user"), + to_attr="_program_scores", + ) + + projects_qs = Project.objects.filter(draft=False, id__in=project_ids) + if program.is_distributed_evaluation: + projects_qs = projects_qs.filter( + expert_assignments__partner_program=program, + expert_assignments__expert__user=user, + ) + + return ( + projects_qs + .annotate( + rated_count=Count( + "scores__user", + filter=Q(scores__criteria__partner_program=program), + distinct=True, + ) + ) + .prefetch_related(scores_prefetch) + .distinct() + ) + + +def submit_project_scores(*, user, project_id: int, data) -> None: + rating_data, criteria_ids, program = _prepare_project_score_data( + user=user, + project_id=project_id, + data=data, + ) + + serializer = ProjectScoreCreateSerializer( + data=rating_data, + criteria_to_get=criteria_ids, + many=True, + ) + serializer.is_valid(raise_exception=True) + + scores_qs = ProjectScore.objects.filter( + project_id=project_id, + criteria__partner_program=program, + ) + user_has_scores = scores_qs.filter(user_id=user.id).exists() + + if program.max_project_rates: + distinct_raters = scores_qs.values("user_id").distinct().count() + if not user_has_scores and distinct_raters >= program.max_project_rates: + raise MaxProjectRatesReached(program.max_project_rates) + + with transaction.atomic(): + ProjectScore.objects.bulk_create( + [ProjectScore(**item) for item in serializer.validated_data], + update_conflicts=True, + update_fields=["value"], + unique_fields=["criteria", "user", "project"], + ) + + project = Project.objects.select_related("leader").get(id=project_id) + _send_project_rated_email(project=project, program=program) + + +def _prepare_project_score_data(*, user, project_id: int, data) -> tuple[list, list, PartnerProgram]: + rating_data = [dict(criterion) for criterion in data] + criteria_ids = [criterion["criterion_id"] for criterion in rating_data] + + criteria_qs = Criteria.objects.filter(id__in=criteria_ids).select_related( + "partner_program" + ) + partner_program_ids = ( + criteria_qs.values_list("partner_program_id", flat=True).distinct() + ) + if not criteria_qs.exists(): + raise ValueError("Criteria not found") + if partner_program_ids.count() != 1: + raise ValueError("All criteria must belong to the same program") + + program = criteria_qs.first().partner_program + Expert.objects.get(user__id=user.id, programs=program) + + for criterion in rating_data: + criterion["user"] = user.id + criterion["project"] = project_id + criterion["criteria"] = criterion.pop("criterion_id") + + if not PartnerProgramProject.objects.filter( + partner_program=program, + project_id=project_id, + ).exists(): + raise ValueError("Project is not linked to the program") + + if program.is_distributed_evaluation and not ProjectExpertAssignment.objects.filter( + partner_program=program, + project_id=project_id, + expert__user_id=user.id, + ).exists(): + raise ValueError("you are not assigned to rate this project") + + return rating_data, criteria_ids, program + + +def _send_project_rated_email(*, project: Project, program: PartnerProgram) -> None: + send_email.delay( + ProjectRatedParams( + message_type=MessageTypeEnum.PROJECT_RATED.value, + user_id=project.leader.id, + project_name=project.name, + project_id=project.id, + schema_id=2, + program_name=program.name, + ) + ) diff --git a/project_rates/tests/test_distributed_evaluation.py b/project_rates/tests/test_distributed_evaluation.py index 8de2c3ee..29e28649 100644 --- a/project_rates/tests/test_distributed_evaluation.py +++ b/project_rates/tests/test_distributed_evaluation.py @@ -79,7 +79,7 @@ def test_list_projects_with_distribution_returns_only_assigned_projects(self): returned_ids = [item["id"] for item in response.data["results"]] self.assertListEqual(returned_ids, [self.project_1.id]) - @patch("project_rates.views.send_email.delay") + @patch("project_rates.services.send_email.delay") def test_rate_project_with_distribution_rejects_unassigned_expert(self, _mock_delay): self.program.is_distributed_evaluation = True self.program.save(update_fields=["is_distributed_evaluation"]) @@ -97,7 +97,7 @@ def test_rate_project_with_distribution_rejects_unassigned_expert(self, _mock_de ) self.assertFalse(ProjectScore.objects.filter(project=self.project_1).exists()) - @patch("project_rates.views.send_email.delay") + @patch("project_rates.services.send_email.delay") def test_rate_project_with_distribution_accepts_assigned_expert(self, mock_delay): self.program.is_distributed_evaluation = True self.program.save(update_fields=["is_distributed_evaluation"]) diff --git a/project_rates/tests/test_rate_project_api.py b/project_rates/tests/test_rate_project_api.py index 6532a329..2da94e2e 100644 --- a/project_rates/tests/test_rate_project_api.py +++ b/project_rates/tests/test_rate_project_api.py @@ -39,7 +39,7 @@ def _rate_url(self, project_id: int | None = None) -> str: def _payload(self, criteria=None, value: str = "8") -> list[dict]: return [{"criterion_id": (criteria or self.criteria).id, "value": value}] - @patch("project_rates.views.send_email.delay") + @patch("project_rates.services.send_email.delay") def test_expert_can_rate_project_without_distribution(self, send_email_delay): self.client.force_authenticate(self.expert) @@ -61,7 +61,7 @@ def test_expert_can_rate_project_without_distribution(self, send_email_delay): ) send_email_delay.assert_called_once() - @patch("project_rates.views.send_email.delay") + @patch("project_rates.services.send_email.delay") def test_expert_can_update_existing_score(self, send_email_delay): ProjectScore.objects.create( criteria=self.criteria, @@ -87,7 +87,7 @@ def test_expert_can_update_existing_score(self, send_email_delay): self.assertEqual(scores.get().value, "9") send_email_delay.assert_called_once() - @patch("project_rates.views.send_email.delay") + @patch("project_rates.services.send_email.delay") def test_rate_project_rejects_expert_without_program_membership( self, send_email_delay, @@ -109,7 +109,7 @@ def test_rate_project_rejects_expert_without_program_membership( self.assertFalse(ProjectScore.objects.filter(user=outsider).exists()) send_email_delay.assert_not_called() - @patch("project_rates.views.send_email.delay") + @patch("project_rates.services.send_email.delay") def test_rate_project_rejects_project_not_linked_to_program( self, send_email_delay, @@ -131,7 +131,7 @@ def test_rate_project_rejects_project_not_linked_to_program( self.assertFalse(ProjectScore.objects.filter(project=unlinked_project).exists()) send_email_delay.assert_not_called() - @patch("project_rates.views.send_email.delay") + @patch("project_rates.services.send_email.delay") def test_rate_project_rejects_criteria_from_different_programs( self, send_email_delay, @@ -157,7 +157,7 @@ def test_rate_project_rejects_criteria_from_different_programs( self.assertFalse(ProjectScore.objects.filter(project=self.project).exists()) send_email_delay.assert_not_called() - @patch("project_rates.views.send_email.delay") + @patch("project_rates.services.send_email.delay") def test_rate_project_respects_max_project_rates_for_new_expert( self, send_email_delay, @@ -189,7 +189,7 @@ def test_rate_project_respects_max_project_rates_for_new_expert( self.assertFalse(ProjectScore.objects.filter(user=self.other_expert).exists()) send_email_delay.assert_not_called() - @patch("project_rates.views.send_email.delay") + @patch("project_rates.services.send_email.delay") def test_rate_project_validates_numeric_limits(self, send_email_delay): self.client.force_authenticate(self.expert) @@ -204,7 +204,7 @@ def test_rate_project_validates_numeric_limits(self, send_email_delay): self.assertFalse(ProjectScore.objects.filter(project=self.project).exists()) send_email_delay.assert_not_called() - @patch("project_rates.views.send_email.delay") + @patch("project_rates.services.send_email.delay") def test_rate_project_validates_bool_value(self, send_email_delay): bool_criteria = create_rate_criteria(self.program, type="bool") self.client.force_authenticate(self.expert) diff --git a/project_rates/views.py b/project_rates/views.py index 9e6f94d7..cf818a34 100644 --- a/project_rates/views.py +++ b/project_rates/views.py @@ -1,132 +1,50 @@ -from django.contrib.auth import get_user_model -from django.db import transaction -from django.db.models import Count, Prefetch, Q, QuerySet - from rest_framework import generics, status -from rest_framework.exceptions import ValidationError from rest_framework.response import Response from django_filters import rest_framework as filters -from partner_programs.models import PartnerProgram, PartnerProgramProject -from partner_programs.serializers import ProgramProjectFilterRequestSerializer -from partner_programs.utils import filter_program_projects_by_field_name -from projects.models import Project from projects.filters import ProjectFilter -from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore from project_rates.pagination import RateProjectsPagination from project_rates.serializers import ( ProjectScoreCreateSerializer, ProjectListForRateSerializer, ) +from project_rates.services import ( + MaxProjectRatesReached, + extract_project_rate_filters, + get_projects_for_rate_queryset, + get_rate_program, + submit_project_scores, +) from users.models import Expert from users.permissions import IsExpert, IsExpertPost -from vacancy.mapping import ProjectRatedParams, MessageTypeEnum -from vacancy.tasks import send_email - -User = get_user_model() class RateProject(generics.CreateAPIView): serializer_class = ProjectScoreCreateSerializer permission_classes = [IsExpertPost] - def get_needed_data(self) -> tuple[dict, list[int], PartnerProgram]: - data = self.request.data - user_id = self.request.user.id - project_id = self.kwargs.get("project_id") - - criteria_to_get = [ - criterion["criterion_id"] for criterion in data - ] # is needed for validation later - - criteria_qs = Criteria.objects.filter(id__in=criteria_to_get).select_related( - "partner_program" - ) - partner_program_ids = ( - criteria_qs.values_list("partner_program_id", flat=True).distinct() - ) - if not criteria_qs.exists(): - raise ValueError("Criteria not found") - if partner_program_ids.count() != 1: - raise ValueError("All criteria must belong to the same program") - program = criteria_qs.first().partner_program - - Expert.objects.get(user__id=user_id, programs=program) - - for criterion in data: - criterion["user"] = user_id - criterion["project"] = project_id - criterion["criteria"] = criterion.pop("criterion_id") - - if not PartnerProgramProject.objects.filter( - partner_program=program, project_id=project_id - ).exists(): - raise ValueError("Project is not linked to the program") - - if program.is_distributed_evaluation and not ProjectExpertAssignment.objects.filter( - partner_program=program, - project_id=project_id, - expert__user_id=user_id, - ).exists(): - raise ValueError("you are not assigned to rate this project") - - return data, criteria_to_get, program - def create(self, request, *args, **kwargs) -> Response: try: - data, criteria_to_get, program = self.get_needed_data() - project_id = data[0]["project"] - user_id = request.user.id - - serializer = ProjectScoreCreateSerializer( - data=data, criteria_to_get=criteria_to_get, many=True - ) - serializer.is_valid(raise_exception=True) - - scores_qs = ProjectScore.objects.filter( - project_id=project_id, criteria__partner_program=program + submit_project_scores( + user=request.user, + project_id=self.kwargs.get("project_id"), + data=request.data, ) - user_has_scores = scores_qs.filter(user_id=user_id).exists() - - if program.max_project_rates: - distinct_raters = scores_qs.values("user_id").distinct().count() - if not user_has_scores and distinct_raters >= program.max_project_rates: - return Response( - { - "error": "max project rates reached for this program", - "max_project_rates": program.max_project_rates, - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - with transaction.atomic(): - ProjectScore.objects.bulk_create( - [ProjectScore(**item) for item in serializer.validated_data], - update_conflicts=True, - update_fields=["value"], - unique_fields=["criteria", "user", "project"], - ) - - project = Project.objects.select_related("leader").get(id=project_id) - - send_email.delay( - ProjectRatedParams( - message_type=MessageTypeEnum.PROJECT_RATED.value, - user_id=project.leader.id, - project_name=project.name, - project_id=project.id, - schema_id=2, - program_name=program.name, - ) - ) - return Response({"success": True}, status=status.HTTP_201_CREATED) except Expert.DoesNotExist: return Response( {"error": "you have no permission to rate this program"}, status=status.HTTP_403_FORBIDDEN, ) + except MaxProjectRatesReached as e: + return Response( + { + "error": str(e), + "max_project_rates": e.max_project_rates, + }, + status=status.HTTP_400_BAD_REQUEST, + ) except ValueError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) except Exception as e: @@ -144,64 +62,19 @@ def post(self, request, *args, **kwargs): """Allow POST with filters in JSON body.""" return self.list(request, *args, **kwargs) - def _get_program(self) -> PartnerProgram: - return PartnerProgram.objects.get(pk=self.kwargs.get("program_id")) - - def _get_filters(self) -> dict: - """ - Accept filters from JSON body to mirror /partner_programs//projects/filter/: - {"filters": {"case": ["Кейс 1"]}} - """ - if self.request.method != "POST": - return {} - data = getattr(self.request, "data", None) - body_filters = data.get("filters") if isinstance(data, dict) else {} - return body_filters if isinstance(body_filters, dict) else {} - - def get_queryset(self) -> QuerySet[Project]: - program = self._get_program() - - filters_serializer = ProgramProjectFilterRequestSerializer( - data={"filters": self._get_filters()} - ) - filters_serializer.is_valid(raise_exception=True) - field_filters = filters_serializer.validated_data.get("filters", {}) - - try: - program_projects_qs = filter_program_projects_by_field_name( - program, field_filters - ) - except ValueError as e: - raise ValidationError({"filters": str(e)}) - - project_ids = program_projects_qs.values_list("project_id", flat=True) - - scores_prefetch = Prefetch( - "scores", - queryset=ProjectScore.objects.filter( - criteria__partner_program=program - ).select_related("user"), - to_attr="_program_scores", - ) - - projects_qs = Project.objects.filter(draft=False, id__in=project_ids) - if program.is_distributed_evaluation: - projects_qs = projects_qs.filter( - expert_assignments__partner_program=program, - expert_assignments__expert__user=self.request.user, - ) - - return ( - projects_qs - .annotate( - rated_count=Count( - "scores__user", - filter=Q(scores__criteria__partner_program=program), - distinct=True, - ) - ) - .prefetch_related(scores_prefetch) - .distinct() + def _get_program(self): + if not hasattr(self, "_program"): + self._program = get_rate_program(self.kwargs.get("program_id")) + return self._program + + def get_queryset(self): + return get_projects_for_rate_queryset( + program=self._get_program(), + user=self.request.user, + field_filters=extract_project_rate_filters( + self.request.method, + getattr(self.request, "data", None), + ), ) def get_serializer_context(self): From 2a29fa344f35d3862762cc6ebaa272e057941fc8 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 29 May 2026 10:28:31 +0500 Subject: [PATCH 21/32] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8F=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/users.md | 275 +++++++++++++++++- users/services/users_activity.py | 16 +- users/tests.py | 113 ------- users/tests/__init__.py | 0 users/tests/helpers.py | 118 ++++++++ users/tests/test_achievements_api.py | 114 ++++++++ users/tests/test_activity_service.py | 30 ++ .../test_auth_activity.py} | 39 +-- users/tests/test_auth_api.py | 85 ++++++ users/tests/test_cv_api.py | 78 +++++ users/tests/test_models_validators.py | 128 ++++++++ .../tests/test_onboarding_verification_api.py | 111 +++++++ users/tests/test_permissions.py | 90 ++++++ users/tests/test_profile_api.py | 125 ++++++++ users/tests/test_signals.py | 25 ++ users/tests/test_skill_confirmations_api.py | 69 +++++ users/tests/test_user_lists_api.py | 113 +++++++ users/validators.py | 4 +- 18 files changed, 1380 insertions(+), 153 deletions(-) delete mode 100644 users/tests.py create mode 100644 users/tests/__init__.py create mode 100644 users/tests/helpers.py create mode 100644 users/tests/test_achievements_api.py create mode 100644 users/tests/test_activity_service.py rename users/{tests_auth_activity.py => tests/test_auth_activity.py} (72%) create mode 100644 users/tests/test_auth_api.py create mode 100644 users/tests/test_cv_api.py create mode 100644 users/tests/test_models_validators.py create mode 100644 users/tests/test_onboarding_verification_api.py create mode 100644 users/tests/test_permissions.py create mode 100644 users/tests/test_profile_api.py create mode 100644 users/tests/test_signals.py create mode 100644 users/tests/test_skill_confirmations_api.py create mode 100644 users/tests/test_user_lists_api.py diff --git a/docs/modules/users.md b/docs/modules/users.md index 12d33799..89a2a4f1 100644 --- a/docs/modules/users.md +++ b/docs/modules/users.md @@ -1,3 +1,276 @@ # Users -TODO +## Назначение + +Users отвечает за учетные записи и профиль пользователя в Procollab: +регистрацию, подтверждение email, авторизацию, роли, публичные данные профиля, +достижения, навыки, CV, активность пользователя и связи пользователя с +проектами, программами, событиями и новостями. + +## Статус модуля + +Модуль рабочий, но находится в состоянии технического долга. Он исторически +содержит несколько разных доменных flow в крупных файлах `users/views.py`, +`users/serializers.py` и `users/helpers.py`. + +Перед активным рефакторингом модуль требует: + +- фиксации текущего поведения regression-тестами; +- разделения сценариев профиля, достижений, верификации, CV и активности; +- выноса бизнес-логики обновления профиля из serializers/helpers в service + layer; +- уточнения legacy-полей профиля и старых форматов payload. + +## Основные возможности + +- регистрация пользователя; +- подтверждение email; +- повторная отправка письма подтверждения; +- получение и обновление профиля пользователя; +- получение текущего пользователя; +- публичный список пользователей; +- список специалистов: mentors, experts, investors; +- роли пользователя: member, mentor, expert, investor; +- дополнительные данные профиля: образование, опыт, языки, ссылки; +- навыки пользователя и подтверждение навыков другими пользователями; +- достижения пользователя и файлы достижений; +- проекты пользователя, проекты лидера и лайкнутые проекты; +- подписанные проекты пользователя; +- программы пользователя и теги программ; +- события, на которые зарегистрирован пользователь; +- onboarding stage; +- принудительная верификация пользователя администратором; +- скачивание CV; +- отправка CV на email; +- отслеживание `last_login` и `last_activity`; +- новости пользователя через общий модуль `news`. + +## Архитектура + +- `users/models.py` - модель пользователя, роли, достижения, ссылки, + образование, опыт, языки и подтверждения навыков. +- `users/views.py` - HTTP endpoints и orchestration logic для профиля, + регистрации, CV, достижений, проектов, программ и событий. +- `users/serializers.py` - request/response contracts, validation и часть + бизнес-логики обновления профиля. +- `users/helpers.py` - вспомогательная логика подтверждения email, обновления + достижений/ссылок и force verify. +- `users/filters.py` - фильтры списков пользователей и специализаций. +- `users/managers.py` - queryset helpers для пользователей, достижений и + лайков проектов. +- `users/permissions.py` - permissions для достижений и expert-сценариев. +- `users/authentication.py` - JWT authentication с обновлением + `last_activity`. +- `users/signals.py` - side effects при создании/обновлении пользователя и + сбросе пароля. +- `users/tasks.py` - Celery-задача отправки CV на email. +- `users/services/` - подготовка данных для CV и пользовательской активности. +- `users/admin.py` - настройка Django admin. +- `users/tests/` - regression-тесты API, serializers/helpers, permissions, + signals и сервисов модуля. + +## Основные сущности + +- `CustomUser` - пользователь. +- `Member` - профиль участника. +- `Mentor` - профиль ментора. +- `Expert` - профиль эксперта, включая связь с партнерскими программами. +- `Investor` - профиль инвестора. +- `UserAchievement` - достижение пользователя. +- `UserAchievementFile` - файл достижения. +- `UserLink` - ссылка пользователя. +- `UserEducation` - образование пользователя. +- `UserWorkExperience` - опыт работы пользователя. +- `UserLanguages` - язык пользователя. +- `UserSkillConfirmation` - подтверждение навыка пользователя другим + пользователем. +- `LikesOnProject` - лайк проекта пользователем. + +## API + +- `POST /auth/users/` - регистрация пользователя. +- `GET /auth/users/` - список пользователей для admin. +- `GET /auth/public-users/` - публичный список пользователей. +- `GET /auth/specialists/` - список специалистов. +- `GET /auth/users//` - детали пользователя. +- `PUT /auth/users//` - полное обновление профиля. +- `PATCH /auth/users//` - частичное обновление профиля. +- `DELETE /auth/users//` - удаление пользователя. +- `GET /auth/users/current/` - профиль текущего пользователя. +- `GET /auth/users/projects/` - проекты текущего пользователя. +- `GET /auth/users/projects/leader/` - проекты, где текущий пользователь лидер. +- `GET /auth/users/liked/` - лайкнутые проекты текущего пользователя. +- `GET /auth/users//subscribed_projects/` - подписки пользователя на + проекты. +- `GET /auth/users/current/programs/` - программы текущего пользователя. +- `GET /auth/users/current/programs/tags/` - теги программ текущего + пользователя. +- `GET /auth/users/current/events/` - события текущего пользователя. +- `GET /auth/users/roles/` - дополнительные роли пользователей. +- `GET /auth/users/types/` - типы пользователей. +- `GET /auth/users/specializations/nested/` - категории специализаций с + вложенными специализациями. +- `GET /auth/users/specializations/inline/` - плоский список специализаций. +- `GET /auth/users/achievements/` - список достижений. +- `POST /auth/users/achievements/` - создание достижения текущего пользователя. +- `GET /auth/users/achievements//` - детали достижения. +- `PUT /auth/users/achievements//` - полное обновление достижения. +- `PATCH /auth/users/achievements//` - обновление достижения. +- `DELETE /auth/users/achievements//` - удаление достижения. +- `PUT /auth/users//set_onboarding_stage/` - обновление стадии онбординга. +- `POST /auth/users//force_verify/` - принудительная верификация + пользователя администратором. +- `POST /auth/users//approve_skill//` - подтверждение навыка. +- `DELETE /auth/users//approve_skill//` - удаление + подтверждения навыка. +- `GET /auth/users/download_cv/` - скачивание CV текущего пользователя. +- `GET /auth/users/send_mail_cv/` - отправка CV текущего пользователя на email. +- `POST /auth/logout/` - logout через blacklist refresh token. +- `POST /auth/resend_email/` - повторная отправка письма подтверждения. +- `GET /auth/account-confirm-email/` - подтверждение email по query token. +- `GET /auth/account-confirm-email//` - legacy route подтверждения email. +- `POST /auth/reset_password/` - сброс пароля через + `django_rest_passwordreset`. +- `GET /auth/users//news/` - новости пользователя. +- `POST /auth/users//news/` - создание новости пользователя. +- `GET /auth/users//news//` - детальная новость пользователя. +- `PATCH /auth/users//news//` - редактирование новости + пользователя. +- `DELETE /auth/users//news//` - удаление новости пользователя. +- `POST /auth/users//news//set_viewed/` - просмотр новости. +- `POST /auth/users//news//set_liked/` - лайк новости. + +## Основные сценарии + +### 1. Регистрация и подтверждение email + +Пользователь регистрируется через `POST /auth/users/`. После создания учетной +записи пользователь остается неактивным до подтверждения email. + +Система отправляет письмо с token. При переходе по ссылке подтверждения +пользователь активируется и получает access/refresh token для входа в сервис. + +### 2. Профиль пользователя + +Пользователь получает и обновляет свой профиль через `/auth/users//` или +`/auth/users/current/`. + +Профиль включает: + +- базовые поля пользователя; +- роль и данные роли; +- навыки; +- образование; +- опыт работы; +- языки; +- ссылки; +- достижения; +- проекты и программы пользователя. + +Телефон отображается только владельцу профиля, потому что используется в CV. + +### 3. Роли пользователя + +У пользователя есть основной `user_type`: + +- member; +- mentor; +- expert; +- investor. + +При создании пользователя сигнал создает соответствующий role-profile: +`Member`, `Mentor`, `Expert` или `Investor`. + +### 4. Навыки и подтверждения + +Навыки пользователя хранятся через `core.SkillToObject`. + +Другой авторизованный пользователь может подтвердить навык через +`/auth/users//approve_skill//`. Пользователь не может +подтверждать собственные навыки. + +### 5. Достижения + +Достижения пользователя доступны через `/auth/users/achievements/`. + +Создавать достижения можно только для текущего пользователя. Файлы достижения +привязываются через `UserFile` и должны принадлежать текущему пользователю. + +### 6. Проекты, программы и события + +Модуль отдает связанные с пользователем данные: + +- проекты текущего пользователя; +- проекты, где пользователь является лидером; +- лайкнутые проекты; +- подписанные проекты; +- программы пользователя; +- события, на которые пользователь зарегистрирован. + +Основная бизнес-логика этих сущностей находится в связанных модулях +`projects`, `partner_programs` и `events`. + +### 7. CV + +Пользователь может скачать CV в PDF или отправить его на свой email. + +PDF собирается из данных профиля пользователя. Для защиты от повторных +запросов используется короткий cache cooldown. + +### 8. Активность пользователя + +JWT authentication обновляет `last_activity` пользователя не чаще одного раза +за throttle window. Если cache временно недоступен, сервис пытается обновить +активность напрямую в базе и не блокирует основной запрос пользователя. + +## Ограничения и правила + +- Email пользователя уникален. +- Новый пользователь создается с `is_active = False` до подтверждения email. +- Профиль может редактировать только владелец. +- `email`, `password` и `is_active` не обновляются через обычный update + профиля. +- Пользователь с типом `member` не может менять `user_type` через текущий flow. +- Телефон скрыт от других пользователей. +- Файлы достижений должны принадлежать текущему пользователю. +- Скачивание и отправка CV ограничены cooldown. +- `last_activity` обновляется с throttle, чтобы не писать в базу на каждый + запрос. +- В модуле остаются legacy-поля `key_skills` и `speciality`; актуальные поля - + `skills` и `v2_speciality`. + +## Тесты + +Текущие тесты лежат в `users/tests/` и разделены по сценариям. + +### API и пользовательские сценарии + +- `test_auth_api.py` - регистрация, duplicate email, invalid payload, + `last_login`, `/auth/users/current/` и удаленные legacy routes. +- `test_profile_api.py` - обновление профиля владельцем, nested profile data, + skills, links, защита чужого профиля, скрытие телефона и неизменяемость + `user_type` для member. +- `test_achievements_api.py` - создание достижений, запрет создания за другого + пользователя, привязка файлов и запрет чужих файлов. +- `test_onboarding_verification_api.py` - onboarding stage, resend verify email, + force verify и role-profile signal. +- `test_skill_confirmations_api.py` - подтверждение навыков другим + пользователем, запрет self-confirmation и удаление подтверждения. +- `test_user_lists_api.py` - публичные списки пользователей, фильтры, + проекты пользователя, проекты лидера и лайкнутые проекты. +- `test_cv_api.py` - скачивание CV, отправка CV на email и cooldown. + +### Бизнес-логика и инфраструктурные сценарии + +- `test_auth_activity.py` - `last_activity` с throttle, устойчивость к ошибкам + cache и database update. +- `test_models_validators.py` - role-profile creation, ordering score, + validation языков, опыта, файлов достижений, лайков, возраста, имени, года и + телефона. +- `test_permissions.py` - permissions для достижений, expert-flow и + отключаемой authentication. +- `test_signals.py` - `dataset_migration_applied` и создание role-profile. +- `test_activity_service.py` - подготовка данных пользовательской активности, + отдельный подсчет участия в программах и проектов, поданных в программу. + +Текущий уровень покрытия модуля по `coverage` - около 82%. diff --git a/users/services/users_activity.py b/users/services/users_activity.py index 1f3a7ffa..041f252f 100644 --- a/users/services/users_activity.py +++ b/users/services/users_activity.py @@ -92,6 +92,13 @@ def __prepare_user_data(self, user: CustomUser) -> dict[str, Any]: def __get_user_queryset(self) -> QuerySet[CustomUser]: user_content_type = ContentType.objects.get_for_model(CustomUser) + program_profiles_count_subquery = ( + PartnerProgramUserProfile.objects + .filter(user_id=OuterRef("id")) + .values("user_id") + .annotate(total=Count("id")) + .values("total") + ) projects_in_program_subquery = ( PartnerProgramUserProfile.objects .filter(user_id=OuterRef("id")) @@ -114,13 +121,6 @@ def __get_user_queryset(self) -> QuerySet[CustomUser]: .values("total_likes") ) - projects_in_program_subquery = ( - CustomUser.objects - .filter(partner_program_profiles__user_id=OuterRef("id")) - .annotate(total_proj=Count("id")) - .values("total_proj") - ) - users: QuerySet[CustomUser] = ( CustomUser.objects .prefetch_related( @@ -140,7 +140,7 @@ def __get_user_queryset(self) -> QuerySet[CustomUser]: output_field=IntegerField(), ), program_profiles_count=Coalesce( - Subquery(projects_in_program_subquery, output_field=IntegerField()), + Subquery(program_profiles_count_subquery, output_field=IntegerField()), Value(0), output_field=IntegerField(), ), diff --git a/users/tests.py b/users/tests.py deleted file mode 100644 index e2cafeb4..00000000 --- a/users/tests.py +++ /dev/null @@ -1,113 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIRequestFactory, force_authenticate -from tests.constants import USER_CREATE_DATA - -from projects.models import Collaborator, Project -from users.models import CustomUser -from users.serializers import UserDetailSerializer -from users.views import CurrentUser, UserLeaderProjectsList, UserList, UserDetail - - -class UserTestCase(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.user_list_view = UserList.as_view() - self.user_detail_view = UserDetail.as_view() - self.user_leader_projects_view = UserLeaderProjectsList.as_view() - self.current_user_view = CurrentUser.as_view() - - def test_user_creation(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data["email"], "only_for_test@test.test") - self.assertEqual(response.data["is_active"], False) - - def test_user_creation_with_wrong_data(self): - request = self.factory.post( - "auth/users/", - { - "email": "qwe", - "password": "qwe", - "first_name": "qwe", - "last_name": "qwe", - }, - ) - response = self.user_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_user_creation_with_existing_email(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - self.assertEqual(response.status_code, 201) - response = self.user_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_user_update(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - - request = self.factory.get(f"auth/users/{user.pk}/") - response = self.user_detail_view(request, pk=user.pk) - self.assertEqual(response.status_code, 401) # Unauthorized - - force_authenticate(request, user=user) - response = self.user_detail_view(request, pk=user.pk) - self.assertEqual(response.status_code, 200) - - request = self.factory.patch(f"auth/users/{user.pk}/", {"first_name": "Сергей"}) - force_authenticate(request, user=user) - response = self.user_detail_view(request, pk=user.pk) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["first_name"], "Сергей") - - def test_user_leader_projects_list(self): - leader = self._user_create("leader@example.com") - collaborator = self._user_create("collaborator@example.com") - - leader_project = Project.objects.create(name="Leader project", leader=leader) - second_leader_project = Project.objects.create( - name="Leader project 2", leader=leader, draft=False - ) - other_project = Project.objects.create(name="Other project", leader=collaborator) - Collaborator.objects.create(user=leader, project=other_project, role="Member") - - request = self.factory.get("users/projects/leader/") - force_authenticate(request, user=leader) - response = self.user_leader_projects_view(request) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["count"], 2) - returned_ids = {item["id"] for item in response.data["results"]} - self.assertSetEqual( - returned_ids, {leader_project.id, second_leader_project.id} - ) - - def test_current_user_returns_authenticated_user_profile(self): - user = self._user_create("current@example.com") - - request = self.factory.get("auth/users/current/") - force_authenticate(request, user=user) - response = self.current_user_view(request) - expected_data = UserDetailSerializer(user, context={"request": request}).data - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, expected_data) - - def test_removed_legacy_routes_return_404(self): - self.assertEqual(self.client.get("/auth/users/clone-data").status_code, 404) - self.assertEqual(self.client.get("/auth/subscription/").status_code, 404) - self.assertEqual(self.client.post("/auth/subscription/buy/").status_code, 404) - - def _user_create(self, email): - tmp_create_data = USER_CREATE_DATA.copy() - tmp_create_data["email"] = email - request = self.factory.post("auth/users/", tmp_create_data) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - user.is_active = True - user.save() - return user diff --git a/users/tests/__init__.py b/users/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/users/tests/helpers.py b/users/tests/helpers.py new file mode 100644 index 00000000..d6eeef2d --- /dev/null +++ b/users/tests/helpers.py @@ -0,0 +1,118 @@ +from datetime import date, timedelta + +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone + +from core.models import Skill, SkillCategory, SkillToObject, Specialization, SpecializationCategory +from files.models import UserFile +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from projects.models import Project +from users.models import CustomUser + + +def build_user( + email: str = "user@example.com", + *, + password: str = "very_strong_password", + first_name: str = "Иван", + last_name: str = "Иванов", + user_type: int = CustomUser.MEMBER, + is_active: bool = True, + **extra_fields, +) -> CustomUser: + defaults = { + "email": email, + "password": password, + "first_name": first_name, + "last_name": last_name, + "birthday": date(2000, 1, 1), + "user_type": user_type, + "is_active": is_active, + } + defaults.update(extra_fields) + return CustomUser.objects.create_user(**defaults) + + +def build_superuser(email: str = "admin@example.com") -> CustomUser: + return CustomUser.objects.create_superuser( + email=email, + password="very_strong_password", + first_name="Админ", + last_name="Админов", + ) + + +def build_skill(name: str = "Python") -> Skill: + category, _ = SkillCategory.objects.get_or_create(name="Backend") + return Skill.objects.create(name=name, category=category) + + +def attach_skill(user: CustomUser, skill: Skill) -> SkillToObject: + return SkillToObject.objects.create( + skill=skill, + content_type=ContentType.objects.get_for_model(CustomUser), + object_id=user.id, + ) + + +def build_specialization(name: str = "Backend developer") -> Specialization: + category, _ = SpecializationCategory.objects.get_or_create(name="IT") + return Specialization.objects.create(name=name, category=category) + + +def build_user_file( + user: CustomUser, + *, + link: str = "https://cdn.example.com/file.pdf", + extension: str = "pdf", + size: int = 1024, +) -> UserFile: + return UserFile.objects.create( + user=user, + link=link, + name="file", + extension=extension, + mime_type="application/pdf", + size=size, + ) + + +def build_project( + leader: CustomUser, + *, + name: str = "Проект", + draft: bool = False, +) -> Project: + return Project.objects.create(name=name, leader=leader, draft=draft) + + +def build_partner_program( + *, + name: str = "Программа", + tag: str = "program", + draft: bool = False, +) -> PartnerProgram: + now = timezone.now() + return PartnerProgram.objects.create( + name=name, + tag=tag, + city="Екатеринбург", + draft=draft, + datetime_started=now - timedelta(days=1), + datetime_registration_ends=now + timedelta(days=10), + datetime_finished=now + timedelta(days=20), + ) + + +def add_user_to_program( + user: CustomUser, + program: PartnerProgram, + *, + project: Project | None = None, +) -> PartnerProgramUserProfile: + return PartnerProgramUserProfile.objects.create( + user=user, + project=project, + partner_program=program, + partner_program_data={}, + ) diff --git a/users/tests/test_achievements_api.py b/users/tests/test_achievements_api.py new file mode 100644 index 00000000..52e5bb18 --- /dev/null +++ b/users/tests/test_achievements_api.py @@ -0,0 +1,114 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from users.models import UserAchievement + +from .helpers import build_user, build_user_file + + +class UserAchievementAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="achievements@example.com") + self.client.force_authenticate(user=self.user) + + def test_user_can_create_achievement_with_owned_file(self): + user_file = build_user_file(self.user) + + response = self.client.post( + "/auth/users/achievements/", + { + "title": "Победа", + "status": "Первое место", + "year": 2024, + "file_links": [user_file.link], + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + achievement = UserAchievement.objects.get(user=self.user) + self.assertEqual(achievement.files.get(), user_file) + + def test_user_cannot_create_achievement_for_another_user(self): + other_user = build_user(email="achievement-owner@example.com") + + response = self.client.post( + "/auth/users/achievements/", + { + "user": other_user.id, + "title": "Победа", + "status": "Первое место", + "year": 2024, + }, + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse(UserAchievement.objects.filter(user=other_user).exists()) + + def test_user_cannot_attach_foreign_file_to_achievement(self): + other_user = build_user(email="file-owner@example.com") + foreign_file = build_user_file( + other_user, + link="https://cdn.example.com/foreign.pdf", + ) + + response = self.client.post( + "/auth/users/achievements/", + { + "title": "Победа", + "status": "Первое место", + "year": 2024, + "file_links": [foreign_file.link], + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(UserAchievement.objects.filter(user=self.user).exists()) + + def test_profile_update_replaces_achievements_and_validates_owned_files(self): + old_achievement = UserAchievement.objects.create( + user=self.user, + title="Старое достижение", + status="Участник", + year=2023, + ) + user_file = build_user_file(self.user) + + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "achievements": [ + { + "title": "Новое достижение", + "status": "Победитель", + "year": 2024, + "file_links": [user_file.link], + } + ] + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(UserAchievement.objects.filter(id=old_achievement.id).exists()) + achievement = UserAchievement.objects.get(user=self.user) + self.assertEqual(achievement.title, "Новое достижение") + self.assertEqual(achievement.files.get(), user_file) + + def test_profile_update_rejects_duplicate_achievements(self): + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "achievements": [ + {"title": "Победа", "status": "Первое место", "year": 2024}, + {"title": "Победа", "status": "Первое место", "year": 2024}, + ] + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(UserAchievement.objects.filter(user=self.user).exists()) diff --git a/users/tests/test_activity_service.py b/users/tests/test_activity_service.py new file mode 100644 index 00000000..ab61392b --- /dev/null +++ b/users/tests/test_activity_service.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from users.services.users_activity import UserActivityDataPreparer + +from .helpers import add_user_to_program, build_partner_program, build_project, build_user + + +class UserActivityDataPreparerTests(TestCase): + def test_activity_data_counts_program_membership_and_program_project_separately(self): + user = build_user(email="activity-report@example.com") + program = build_partner_program() + add_user_to_program(user, program) + + data = UserActivityDataPreparer().get_users_prepared_data() + user_row = next(row for row in data if row["ID пользователя"] == user.id) + + self.assertEqual(user_row["Участие в программах кол-во"], 1) + self.assertEqual(user_row["Кол-во проектов в программе"], 0) + + def test_activity_data_counts_project_submitted_to_program(self): + user = build_user(email="activity-project@example.com") + project = build_project(user) + program = build_partner_program(tag="project-program") + add_user_to_program(user, program, project=project) + + data = UserActivityDataPreparer().get_users_prepared_data() + user_row = next(row for row in data if row["ID пользователя"] == user.id) + + self.assertEqual(user_row["Участие в программах кол-во"], 1) + self.assertEqual(user_row["Кол-во проектов в программе"], 1) diff --git a/users/tests_auth_activity.py b/users/tests/test_auth_activity.py similarity index 72% rename from users/tests_auth_activity.py rename to users/tests/test_auth_activity.py index 57a398e7..2d56fe7b 100644 --- a/users/tests_auth_activity.py +++ b/users/tests/test_auth_activity.py @@ -3,25 +3,18 @@ from django.core.cache import cache from django.urls import reverse -from django.utils import timezone from rest_framework.test import APIClient, APITestCase from users.authentication import get_last_activity_cache_key -from users.models import CustomUser + +from .helpers import build_user class JwtActivityTrackingTests(APITestCase): def setUp(self): self.email = "activity_test@example.com" self.password = "very_strong_password" - self.user = CustomUser.objects.create_user( - email=self.email, - password=self.password, - first_name="Иван", - last_name="Иванов", - birthday="2000-01-01", - is_active=True, - ) + self.user = build_user(email=self.email, password=self.password) def _obtain_access_token(self) -> str: response = self.client.post( @@ -32,24 +25,9 @@ def _obtain_access_token(self) -> str: self.assertEqual(response.status_code, 200) return response.data["access"] - def test_token_obtain_pair_updates_last_login(self): - old_login = timezone.now() - timedelta(days=1) - CustomUser.objects.filter(id=self.user.id).update(last_login=old_login) - - response = self.client.post( - reverse("token_obtain_pair"), - {"email": self.email, "password": self.password}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - self.user.refresh_from_db() - self.assertIsNotNone(self.user.last_login) - self.assertGreater(self.user.last_login, old_login) - def test_last_activity_updates_with_throttle(self): cache.delete(get_last_activity_cache_key(self.user.id)) - CustomUser.objects.filter(id=self.user.id).update(last_activity=None) + self.user.__class__.objects.filter(id=self.user.id).update(last_activity=None) access = self._obtain_access_token() api_client = APIClient() @@ -66,9 +44,10 @@ def test_last_activity_updates_with_throttle(self): self.user.refresh_from_db() self.assertEqual(self.user.last_activity, first_activity) - # Simulate throttle window end for deterministic testing. old_activity = first_activity - timedelta(hours=1) - CustomUser.objects.filter(id=self.user.id).update(last_activity=old_activity) + self.user.__class__.objects.filter(id=self.user.id).update( + last_activity=old_activity + ) cache.delete(get_last_activity_cache_key(self.user.id)) third_response = api_client.get("/auth/specialists/") @@ -78,13 +57,14 @@ def test_last_activity_updates_with_throttle(self): @patch("users.authentication.cache.add", side_effect=Exception("cache is down")) def test_last_activity_cache_failure_does_not_break_auth(self, _cache_add_mock): - CustomUser.objects.filter(id=self.user.id).update(last_activity=None) + self.user.__class__.objects.filter(id=self.user.id).update(last_activity=None) access = self._obtain_access_token() api_client = APIClient() api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}") response = api_client.get("/auth/specialists/") + self.assertEqual(response.status_code, 200) self.user.refresh_from_db() self.assertIsNotNone(self.user.last_activity) @@ -102,6 +82,7 @@ def test_last_activity_db_failure_does_not_break_auth(self, get_user_model_mock) api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}") response = api_client.get("/auth/specialists/") + self.assertEqual(response.status_code, 200) fake_model.objects.filter.assert_called_once_with(id=self.user.id) fake_qs.update.assert_called_once() diff --git a/users/tests/test_auth_api.py b/users/tests/test_auth_api.py new file mode 100644 index 00000000..4cff999b --- /dev/null +++ b/users/tests/test_auth_api.py @@ -0,0 +1,85 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from tests.constants import USER_CREATE_DATA +from users.models import CustomUser +from users.serializers import UserDetailSerializer + +from .helpers import build_user + + +class UserRegistrationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("users.views.verify_email") + def test_user_registration_creates_inactive_user_and_sends_email(self, verify_email_mock): + response = self.client.post("/auth/users/", USER_CREATE_DATA, format="json") + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["email"], USER_CREATE_DATA["email"]) + self.assertEqual(response.data["is_active"], False) + user = CustomUser.objects.get(email=USER_CREATE_DATA["email"]) + verify_email_mock.assert_called_once() + self.assertEqual(verify_email_mock.call_args.args[0], user) + + @patch("users.views.verify_email") + def test_user_registration_rejects_duplicate_email(self, _verify_email_mock): + first_response = self.client.post("/auth/users/", USER_CREATE_DATA, format="json") + second_response = self.client.post("/auth/users/", USER_CREATE_DATA, format="json") + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(second_response.status_code, 400) + + def test_user_registration_rejects_invalid_payload(self): + response = self.client.post( + "/auth/users/", + { + "email": "wrong-email", + "password": "qwe", + "first_name": "И", + "last_name": "Иванов", + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + def test_token_obtain_pair_updates_last_login(self): + user = build_user(email="login@example.com") + + response = self.client.post( + reverse("token_obtain_pair"), + {"email": user.email, "password": "very_strong_password"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + user.refresh_from_db() + self.assertIsNotNone(user.last_login) + + +class CurrentUserAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_current_user_returns_authenticated_user_profile(self): + user = build_user(email="current@example.com") + self.client.force_authenticate(user=user) + + response = self.client.get("/auth/users/current/") + expected_data = UserDetailSerializer( + user, + context={"request": response.wsgi_request}, + ).data + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, expected_data) + + def test_removed_legacy_routes_return_404(self): + self.assertEqual(self.client.get("/auth/users/clone-data").status_code, 404) + self.assertEqual(self.client.get("/auth/subscription/").status_code, 404) + self.assertEqual(self.client.post("/auth/subscription/buy/").status_code, 404) diff --git a/users/tests/test_cv_api.py b/users/tests/test_cv_api.py new file mode 100644 index 00000000..a67033b7 --- /dev/null +++ b/users/tests/test_cv_api.py @@ -0,0 +1,78 @@ +from unittest.mock import MagicMock, patch + +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient + +from .helpers import build_user + + +class UserCVAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = build_user(email="cv@example.com") + self.client.force_authenticate(user=self.user) + + @patch("users.views.HTML") + @patch("users.views.render_to_string", return_value="cv") + @patch("users.views.UserCVDataPreparerV2") + def test_user_can_download_cv_pdf( + self, + data_preparer_mock, + _render_to_string_mock, + html_mock, + ): + preparer = MagicMock() + preparer.TEMPLATE_PATH = "template.html" + preparer.get_prepared_data.return_value = {"base_user_info": self.user} + data_preparer_mock.return_value = preparer + html_mock.return_value.write_pdf.return_value = b"pdf" + + response = self.client.get("/auth/users/download_cv/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/pdf") + self.assertEqual(response.content, b"pdf") + + @patch("users.views.HTML") + @patch("users.views.render_to_string", return_value="cv") + @patch("users.views.UserCVDataPreparerV2") + def test_cv_download_has_cooldown( + self, + data_preparer_mock, + _render_to_string_mock, + html_mock, + ): + preparer = MagicMock() + preparer.TEMPLATE_PATH = "template.html" + preparer.get_prepared_data.return_value = {"base_user_info": self.user} + data_preparer_mock.return_value = preparer + html_mock.return_value.write_pdf.return_value = b"pdf" + + first_response = self.client.get("/auth/users/download_cv/") + second_response = self.client.get("/auth/users/download_cv/") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 400) + self.assertIn("seconds_after_retry", second_response.data) + + @patch("users.views.send_mail_cv.delay") + def test_user_can_schedule_cv_email(self, delay_mock): + response = self.client.get("/auth/users/send_mail_cv/") + + self.assertEqual(response.status_code, 200) + delay_mock.assert_called_once_with( + user_id=self.user.id, + user_email=self.user.email, + filename=f"{self.user.first_name}_{self.user.last_name}", + ) + + @patch("users.views.send_mail_cv.delay") + def test_cv_email_has_cooldown(self, delay_mock): + first_response = self.client.get("/auth/users/send_mail_cv/") + second_response = self.client.get("/auth/users/send_mail_cv/") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 400) + self.assertEqual(delay_mock.call_count, 1) diff --git a/users/tests/test_models_validators.py b/users/tests/test_models_validators.py new file mode 100644 index 00000000..ef00d232 --- /dev/null +++ b/users/tests/test_models_validators.py @@ -0,0 +1,128 @@ +from datetime import date + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.test import TestCase +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from users import constants +from users.models import ( + LikesOnProject, + UserAchievement, + UserAchievementFile, + UserLanguages, + UserWorkExperience, +) +from users.validators import ( + user_birthday_validator, + user_experience_years_range_validator, + user_name_validator, + user_phone_number_validation, +) + +from .helpers import build_project, build_user, build_user_file + + +class UserModelValidationTests(TestCase): + def test_role_profile_is_created_for_new_user(self): + user = build_user(email="role@example.com") + + self.assertTrue(hasattr(user, "member")) + + def test_ordering_score_is_recalculated_after_profile_change(self): + user = build_user(email="ordering@example.com") + user.about_me = "О себе" + user.city = "Москва" + user.save() + + user.refresh_from_db() + self.assertGreater(user.ordering_score, 0) + + def test_user_language_limit_is_enforced(self): + user = build_user(email="languages@example.com") + for language in list(constants.UserLanguagesEnum)[:4]: + UserLanguages.objects.create( + user=user, + language=language.value, + language_level=constants.UserLanguagesLevels.B1.value, + ) + + with self.assertRaises(DjangoValidationError): + UserLanguages.objects.create( + user=user, + language=constants.UserLanguagesEnum.FRENCH.value, + language_level=constants.UserLanguagesLevels.B1.value, + ) + + def test_work_experience_rejects_completion_before_entry(self): + user = build_user(email="experience@example.com") + + with self.assertRaises(DjangoValidationError): + UserWorkExperience.objects.create( + user=user, + organization_name="Компания", + entry_year=2024, + completion_year=2020, + ) + + def test_achievement_file_requires_same_owner(self): + owner = build_user(email="achievement-file-owner@example.com") + other_user = build_user(email="foreign-file-owner@example.com") + achievement = UserAchievement.objects.create( + user=owner, + title="Победа", + status="Первое место", + year=2024, + ) + foreign_file = build_user_file( + other_user, + link="https://cdn.example.com/foreign-achievement.pdf", + ) + + link = UserAchievementFile(achievement=achievement, file=foreign_file) + + with self.assertRaises(DjangoValidationError): + link.clean() + + def test_project_like_manager_toggles_existing_like(self): + user = build_user(email="likes@example.com") + project = build_project(user) + + first_like = LikesOnProject.objects.toggle_like(user, project) + second_like = LikesOnProject.objects.toggle_like(user, project) + + self.assertTrue(first_like.is_liked) + self.assertEqual(first_like.id, second_like.id) + second_like.refresh_from_db() + self.assertFalse(second_like.is_liked) + + +class UserValidatorsTests(TestCase): + def test_birthday_validator_accepts_adult_user(self): + birthday = timezone.now().date().replace(year=timezone.now().date().year - 20) + + self.assertTrue(user_birthday_validator(birthday)) + + def test_birthday_validator_rejects_too_young_user(self): + birthday = timezone.now().date().replace(year=timezone.now().date().year - 10) + + with self.assertRaises(ValidationError): + user_birthday_validator(birthday) + + def test_birthday_validator_rejects_user_older_than_100(self): + birthday = date(timezone.now().date().year - 101, 1, 1) + + with self.assertRaises(ValidationError): + user_birthday_validator(birthday) + + def test_name_validator_rejects_non_cyrillic_value(self): + with self.assertRaises(DjangoValidationError): + user_name_validator("John", field_name="Имя") + + def test_experience_year_validator_rejects_out_of_range_year(self): + with self.assertRaises(DjangoValidationError): + user_experience_years_range_validator(1900) + + def test_phone_validator_rejects_invalid_phone_number(self): + with self.assertRaises(DjangoValidationError): + user_phone_number_validation("wrong-phone") diff --git a/users/tests/test_onboarding_verification_api.py b/users/tests/test_onboarding_verification_api.py new file mode 100644 index 00000000..e7ad2678 --- /dev/null +++ b/users/tests/test_onboarding_verification_api.py @@ -0,0 +1,111 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from users.models import CustomUser + +from .helpers import build_superuser, build_user + + +class UserOnboardingAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="onboarding@example.com") + self.client.force_authenticate(user=self.user) + + def test_user_can_update_own_onboarding_stage(self): + response = self.client.put( + f"/auth/users/{self.user.id}/set_onboarding_stage/", + {"onboarding_stage": 1}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.onboarding_stage, 1) + + def test_user_cannot_update_another_user_onboarding_stage(self): + other_user = build_user(email="other-onboarding@example.com") + + response = self.client.put( + f"/auth/users/{other_user.id}/set_onboarding_stage/", + {"onboarding_stage": 1}, + format="json", + ) + + self.assertEqual(response.status_code, 403) + other_user.refresh_from_db() + self.assertNotEqual(other_user.onboarding_stage, 1) + + def test_user_cannot_set_invalid_onboarding_stage(self): + response = self.client.put( + f"/auth/users/{self.user.id}/set_onboarding_stage/", + {"onboarding_stage": 4}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + + +class UserVerificationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("users.views.verify_email") + def test_resend_verify_email_for_inactive_user(self, verify_email_mock): + user = build_user(email="inactive@example.com", is_active=False) + + response = self.client.post( + "/auth/resend_email/", + {"email": user.email}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + verify_email_mock.assert_called_once() + + @patch("users.views.verify_email") + def test_resend_verify_email_does_not_send_for_active_user(self, verify_email_mock): + user = build_user(email="active@example.com", is_active=True) + + response = self.client.post( + "/auth/resend_email/", + {"email": user.email}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + verify_email_mock.assert_not_called() + + def test_admin_can_force_verify_user(self): + admin = build_superuser() + user = build_user(email="force@example.com", is_active=False) + self.client.force_authenticate(user=admin) + + response = self.client.post(f"/auth/users/{user.id}/force_verify/") + + self.assertEqual(response.status_code, 200) + user.refresh_from_db() + self.assertTrue(user.is_active) + self.assertEqual(user.verification_date, timezone.now().date()) + + def test_regular_user_cannot_force_verify_user(self): + actor = build_user(email="regular@example.com") + user = build_user(email="target@example.com", is_active=False) + self.client.force_authenticate(user=actor) + + response = self.client.post(f"/auth/users/{user.id}/force_verify/") + + self.assertEqual(response.status_code, 403) + user.refresh_from_db() + self.assertFalse(user.is_active) + + def test_user_type_signal_creates_role_profile(self): + expert = build_user( + email="expert-profile@example.com", + user_type=CustomUser.EXPERT, + ) + + self.assertTrue(hasattr(expert, "expert")) diff --git a/users/tests/test_permissions.py b/users/tests/test_permissions.py new file mode 100644 index 00000000..19d15ae8 --- /dev/null +++ b/users/tests/test_permissions.py @@ -0,0 +1,90 @@ +from types import SimpleNamespace + +from django.test import TestCase +from rest_framework.exceptions import PermissionDenied + +from users.models import CustomUser, UserAchievement +from users.permissions import ( + CustomIsAuthenticated, + IsAchievementOwnerOrReadOnly, + IsExpert, + IsExpertPost, +) + +from .helpers import build_partner_program, build_user + + +class UserPermissionsTests(TestCase): + def test_achievement_owner_can_update_achievement(self): + user = build_user(email="achievement-owner@example.com") + achievement = UserAchievement.objects.create( + user=user, + title="Победа", + status="Первое место", + year=2024, + ) + request = SimpleNamespace(method="PATCH", user=user) + + self.assertTrue( + IsAchievementOwnerOrReadOnly().has_object_permission( + request, + None, + achievement, + ) + ) + + def test_non_owner_cannot_update_achievement(self): + owner = build_user(email="achievement-owner@example.com") + viewer = build_user(email="achievement-viewer@example.com") + achievement = UserAchievement.objects.create( + user=owner, + title="Победа", + status="Первое место", + year=2024, + ) + request = SimpleNamespace(method="PATCH", user=viewer) + + self.assertFalse( + IsAchievementOwnerOrReadOnly().has_object_permission( + request, + None, + achievement, + ) + ) + + def test_expert_permission_allows_program_expert(self): + user = build_user( + email="expert@example.com", + user_type=CustomUser.EXPERT, + ) + program = build_partner_program() + user.expert.programs.add(program) + request = SimpleNamespace(user=user) + view = SimpleNamespace(kwargs={"program_id": program.id}) + + self.assertTrue(IsExpert().has_permission(request, view)) + + def test_expert_permission_rejects_outsider(self): + user = build_user(email="member@example.com") + program = build_partner_program() + request = SimpleNamespace(user=user) + view = SimpleNamespace(kwargs={"program_id": program.id}) + + with self.assertRaises(PermissionDenied): + IsExpert().has_permission(request, view) + + def test_expert_post_permission_allows_expert_user_type(self): + user = build_user( + email="expert-post@example.com", + user_type=CustomUser.EXPERT, + ) + request = SimpleNamespace(user=user) + + self.assertTrue(IsExpertPost().has_permission(request, None)) + + def test_custom_is_authenticated_can_be_disabled_by_view_flag(self): + anonymous_user = SimpleNamespace(is_authenticated=False) + request = SimpleNamespace(user=anonymous_user) + view = SimpleNamespace(authentication_off=True) + + self.assertTrue(CustomIsAuthenticated().has_permission(request, view)) diff --git a/users/tests/test_profile_api.py b/users/tests/test_profile_api.py new file mode 100644 index 00000000..2ca4d0d7 --- /dev/null +++ b/users/tests/test_profile_api.py @@ -0,0 +1,125 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from core.models import SkillToObject +from users.constants import UserLanguagesEnum, UserLanguagesLevels +from users.models import CustomUser, UserEducation, UserLanguages, UserLink, UserWorkExperience + +from .helpers import build_skill, build_specialization, build_user + + +class UserProfileUpdateAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="profile@example.com") + self.client.force_authenticate(user=self.user) + + def test_owner_can_update_profile_related_data(self): + skill = build_skill("Django") + specialization = build_specialization("Backend") + + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "first_name": "Сергей", + "city": "Москва", + "phone_number": "+79991234567", + "v2_speciality_id": specialization.id, + "skills_ids": [skill.id], + "education": [ + { + "organization_name": "Университет", + "description": "Информатика", + "entry_year": 2018, + "completion_year": 2022, + "education_level": "Высшее образование – бакалавриат, специалитет", + "education_status": "Выпускник", + } + ], + "work_experience": [ + { + "organization_name": "Компания", + "description": "Backend", + "entry_year": 2022, + "completion_year": 2024, + "job_position": "Разработчик", + } + ], + "user_languages": [ + { + "language": UserLanguagesEnum.ENGLISH.value, + "language_level": UserLanguagesLevels.B2.value, + } + ], + "links": ["https://example.com/profile"], + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, "Сергей") + self.assertEqual(self.user.v2_speciality_id, specialization.id) + self.assertEqual(UserEducation.objects.filter(user=self.user).count(), 1) + self.assertEqual(UserWorkExperience.objects.filter(user=self.user).count(), 1) + self.assertEqual(UserLanguages.objects.filter(user=self.user).count(), 1) + self.assertEqual(UserLink.objects.filter(user=self.user).count(), 1) + self.assertTrue( + SkillToObject.objects.filter(object_id=self.user.id, skill=skill).exists() + ) + + def test_owner_cannot_add_duplicate_languages(self): + response = self.client.patch( + f"/auth/users/{self.user.id}/", + { + "user_languages": [ + { + "language": UserLanguagesEnum.ENGLISH.value, + "language_level": UserLanguagesLevels.B1.value, + }, + { + "language": UserLanguagesEnum.ENGLISH.value, + "language_level": UserLanguagesLevels.B2.value, + }, + ] + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(UserLanguages.objects.filter(user=self.user).exists()) + + def test_user_cannot_update_another_profile(self): + other_user = build_user(email="other@example.com") + + response = self.client.patch( + f"/auth/users/{other_user.id}/", + {"first_name": "Петр"}, + format="json", + ) + + self.assertEqual(response.status_code, 403) + other_user.refresh_from_db() + self.assertNotEqual(other_user.first_name, "Петр") + + def test_phone_number_is_hidden_from_other_users(self): + self.user.phone_number = "+79991234567" + self.user.save() + viewer = build_user(email="viewer@example.com") + self.client.force_authenticate(user=viewer) + + response = self.client.get(f"/auth/users/{self.user.id}/") + + self.assertEqual(response.status_code, 200) + self.assertNotIn("phone_number", response.data) + + def test_member_user_type_is_immutable_in_profile_update(self): + response = self.client.patch( + f"/auth/users/{self.user.id}/", + {"user_type": CustomUser.EXPERT}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.user_type, CustomUser.MEMBER) diff --git a/users/tests/test_signals.py b/users/tests/test_signals.py new file mode 100644 index 00000000..8983ab14 --- /dev/null +++ b/users/tests/test_signals.py @@ -0,0 +1,25 @@ +from django.test import TestCase + +from users.models import CustomUser + +from .helpers import attach_skill, build_skill, build_specialization, build_user + + +class UserSignalsTests(TestCase): + def test_dataset_migration_flag_is_enabled_when_specialization_and_skills_exist(self): + user = build_user(email="dataset@example.com") + user.v2_speciality = build_specialization() + attach_skill(user, build_skill()) + + with self.captureOnCommitCallbacks(execute=True): + user.save() + user.refresh_from_db() + + self.assertTrue(user.dataset_migration_applied) + + def test_role_signal_creates_expected_profile_for_non_member_types(self): + mentor = build_user(email="mentor@example.com", user_type=CustomUser.MENTOR) + investor = build_user(email="investor@example.com", user_type=CustomUser.INVESTOR) + + self.assertTrue(hasattr(mentor, "mentor")) + self.assertTrue(hasattr(investor, "investor")) diff --git a/users/tests/test_skill_confirmations_api.py b/users/tests/test_skill_confirmations_api.py new file mode 100644 index 00000000..56e80ce0 --- /dev/null +++ b/users/tests/test_skill_confirmations_api.py @@ -0,0 +1,69 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from users.models import UserSkillConfirmation + +from .helpers import attach_skill, build_skill, build_user + + +class UserSkillConfirmationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="skill-owner@example.com") + self.confirming_user = build_user(email="confirmer@example.com") + self.skill = build_skill("Python") + self.skill_to_object = attach_skill(self.user, self.skill) + + def test_user_can_confirm_another_user_skill(self): + self.client.force_authenticate(user=self.confirming_user) + + response = self.client.post( + f"/auth/users/{self.user.id}/approve_skill/{self.skill.id}/" + ) + + self.assertEqual(response.status_code, 201) + self.assertTrue( + UserSkillConfirmation.objects.filter( + skill_to_object=self.skill_to_object, + confirmed_by=self.confirming_user, + ).exists() + ) + + def test_user_cannot_confirm_own_skill(self): + self.client.force_authenticate(user=self.user) + + response = self.client.post( + f"/auth/users/{self.user.id}/approve_skill/{self.skill.id}/" + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(UserSkillConfirmation.objects.exists()) + + def test_user_can_delete_own_skill_confirmation(self): + UserSkillConfirmation.objects.create( + skill_to_object=self.skill_to_object, + confirmed_by=self.confirming_user, + ) + self.client.force_authenticate(user=self.confirming_user) + + response = self.client.delete( + f"/auth/users/{self.user.id}/approve_skill/{self.skill.id}/" + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse(UserSkillConfirmation.objects.exists()) + + def test_user_cannot_delete_another_user_skill_confirmation(self): + other_user = build_user(email="other-confirmer@example.com") + UserSkillConfirmation.objects.create( + skill_to_object=self.skill_to_object, + confirmed_by=other_user, + ) + self.client.force_authenticate(user=self.confirming_user) + + response = self.client.delete( + f"/auth/users/{self.user.id}/approve_skill/{self.skill.id}/" + ) + + self.assertEqual(response.status_code, 404) + self.assertTrue(UserSkillConfirmation.objects.exists()) diff --git a/users/tests/test_user_lists_api.py b/users/tests/test_user_lists_api.py new file mode 100644 index 00000000..80a66b66 --- /dev/null +++ b/users/tests/test_user_lists_api.py @@ -0,0 +1,113 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from users.models import LikesOnProject + +from .helpers import ( + add_user_to_program, + attach_skill, + build_partner_program, + build_project, + build_skill, + build_user, +) + + +class PublicUserListAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_public_users_can_be_filtered_by_fullname(self): + matched_user = build_user( + email="matched@example.com", + first_name="Алексей", + last_name="Петров", + ) + build_user( + email="not-matched@example.com", + first_name="Иван", + last_name="Сидоров", + ) + + response = self.client.get("/auth/public-users/?fullname=Алексей Петров") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertEqual(returned_ids, {matched_user.id}) + + def test_public_users_can_be_filtered_by_skill(self): + matched_user = build_user(email="skilled@example.com") + other_user = build_user(email="unskilled@example.com") + skill = build_skill("Django") + attach_skill(matched_user, skill) + + response = self.client.get("/auth/public-users/?skills__contains=Django") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertIn(matched_user.id, returned_ids) + self.assertNotIn(other_user.id, returned_ids) + + def test_public_users_can_be_filtered_by_partner_program(self): + matched_user = build_user(email="program-user@example.com") + other_user = build_user(email="not-program-user@example.com") + program = build_partner_program() + add_user_to_program(matched_user, program) + + response = self.client.get(f"/auth/public-users/?partner_program={program.id}") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertIn(matched_user.id, returned_ids) + self.assertNotIn(other_user.id, returned_ids) + + +class UserProjectsAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = build_user(email="projects-user@example.com") + self.client.force_authenticate(user=self.user) + + def test_user_projects_returns_leader_and_collaborator_projects(self): + leader_project = build_project(self.user, name="Leader project") + collaborator_leader = build_user(email="collaborator-leader@example.com") + collaborator_project = build_project( + collaborator_leader, + name="Collaborator project", + ) + collaborator_project.collaborator_set.create(user=self.user, role="Member") + + response = self.client.get("/auth/users/projects/") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertSetEqual(returned_ids, {leader_project.id, collaborator_project.id}) + + def test_user_leader_projects_returns_only_owned_projects(self): + leader_project = build_project(self.user, name="Leader project") + other_leader = build_user(email="other-leader@example.com") + other_project = build_project(other_leader, name="Other project") + other_project.collaborator_set.create(user=self.user, role="Member") + + response = self.client.get("/auth/users/projects/leader/") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data["results"]} + self.assertSetEqual(returned_ids, {leader_project.id}) + + def test_liked_projects_returns_only_active_likes(self): + liked_project = build_project(self.user, name="Liked project") + unliked_project = build_project(self.user, name="Unliked project") + LikesOnProject.objects.create(user=self.user, project=liked_project) + LikesOnProject.objects.create( + user=self.user, + project=unliked_project, + is_liked=False, + ) + + response = self.client.get("/auth/users/liked/") + + self.assertEqual(response.status_code, 200) + returned_ids = {item["id"] for item in response.data} + self.assertIn(liked_project.id, returned_ids) + self.assertNotIn(unliked_project.id, returned_ids) diff --git a/users/validators.py b/users/validators.py index 7d6e506c..7506ae33 100644 --- a/users/validators.py +++ b/users/validators.py @@ -9,11 +9,11 @@ def user_birthday_validator(birthday): """returns true if person > 12 years old""" - if (timezone.now().date() - birthday).days >= 12 * 365: - return True # check if person is > 100 years old if (timezone.now().date() - birthday).days >= 100 * 365: raise ValidationError("Человек старше 100 лет") + if (timezone.now().date() - birthday).days >= 12 * 365: + return True raise ValidationError("Человек младше 12 лет") From defbf8b510f295fd818baee2d8d85011cf06f51e Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 3 Jun 2026 13:34:46 +0500 Subject: [PATCH 22/32] =?UTF-8?q?=D0=98=D1=81=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D0=BE=D1=81=D1=82=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BE=D0=B1=D1=89=D0=B5=D0=B9=20=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/feed.md | 24 ++++++------------------ docs/modules/partner-programs.md | 4 ---- feed/serializers.py | 6 ------ feed/tests/test_feed_api.py | 18 ++++++------------ feed/views.py | 6 +++++- news/tests/test_news_user_program_api.py | 9 +++++++++ 6 files changed, 26 insertions(+), 41 deletions(-) diff --git a/docs/modules/feed.md b/docs/modules/feed.md index 3b7cda7e..06e2ffbe 100644 --- a/docs/modules/feed.md +++ b/docs/modules/feed.md @@ -52,20 +52,12 @@ View выбирает подходящие `news.News`, сериализует ### 2. В ленту попадает обычная новость -Если `news.News` содержит текст и относится к пользователю, проекту или -партнерской программе, лента возвращает ее как новость. +Если `news.News` содержит текст и относится к пользователю или проекту, лента +возвращает ее как новость. Проектная новость с текстом возвращается как `type_model = "news"`, даже если ее `content_object` - проект. -Новость партнерской программы с текстом тоже возвращается как -`type_model = "news"`. Отдельный `type_model = "partner_program"` пока не -вводится. - -Служебные feed-записи партнерских программ сейчас не создаются. Если такой -сценарий понадобится, для него нужно отдельно согласовать `type_model` и -frontend-контракт. - ### 3. В ленту попадает служебная запись Служебные feed-записи создаются через `feed.services.create_news_for_model()`. @@ -91,17 +83,15 @@ frontend-контракт. - `GET /feed/?type=news` - новости пользователей. - `GET /feed/?type=project` - проектные новости и проектные feed-записи. - `GET /feed/?type=vacancy` - служебные feed-записи вакансий. -- `GET /feed/?type=partnerprogram` - новости партнерских программ. -- `GET /feed/?type=project|vacancy|news|partnerprogram` - комбинированная - выдача по нескольким типам. +- `GET /feed/?type=project|vacancy|news` - комбинированная выдача по нескольким + типам. ## Ограничения и правила - Feed читает данные из `news.News`, но не отвечает за создание обычных - project/user/program news. + project/user news. - Служебная feed-запись определяется через пустой `text`. -- Новости партнерских программ с текстом отображаются как обычные новости; - отдельные служебные карточки программ в ленте пока не поддерживаются. +- Новости партнерских программ не отображаются в `/feed/`. - Signals `feed` создают или удаляют служебные feed-записи для проектов и вакансий. Более широкие сценарии публикации проекта остаются в модуле `projects`. @@ -112,8 +102,6 @@ frontend-контракт. - `/feed/?type=news` возвращает пользовательские новости; - `/feed/?type=project` возвращает проектные новости в frontend-формате; -- `/feed/?type=partnerprogram` возвращает новости программ как - `type_model = "news"`; - `/feed/?type=project` возвращает служебную feed-запись проекта как `type_model = "project"`; - `/feed/?type=vacancy` возвращает служебную feed-запись вакансии как diff --git a/docs/modules/partner-programs.md b/docs/modules/partner-programs.md index d31bb234..f185f47e 100644 --- a/docs/modules/partner-programs.md +++ b/docs/modules/partner-programs.md @@ -161,8 +161,6 @@ `PartnerProgramProject`. - `users` - участники и менеджеры программы. - `news` - новости программы создаются и читаются через общий news API. -- `feed` - текстовые новости программы могут попадать в общую ленту как - `type_model = "news"`. - `courses` - курс может быть связан с программой и доступен участникам программы. - `project_rates` - оценки проектов используются в выгрузке результатов. @@ -176,8 +174,6 @@ - Значения дополнительных полей хранятся в `PartnerProgramFieldValue`. - После сдачи проекта в конкурсной программе значения полей редактировать нельзя. -- Служебные feed-карточки программ пока не поддерживаются; новости программ в - feed отображаются как обычные новости. - Основной API-код пока сосредоточен во `views.py`; перед крупным рефакторингом нужно зафиксировать больше regression-тестов. diff --git a/feed/serializers.py b/feed/serializers.py index c412356b..4efead6f 100644 --- a/feed/serializers.py +++ b/feed/serializers.py @@ -6,7 +6,6 @@ from news.mapping import NewsMapping from news.models import News from news.services import is_content_news -from partner_programs.models import PartnerProgram from projects.models import Project from users.models import CustomUser @@ -24,11 +23,6 @@ class FeedNewsContentSerializer(serializers.ModelSerializer): def get_type_model(self, obj) -> str | None: content_model = obj.content_type.model - if content_model == PartnerProgram.__name__.lower(): - # Новости программ сейчас отображаются как обычные новости. - # Отдельная служебная карточка программы в ленте пока не согласована. - return "news" if is_content_news(obj) else None - if is_content_news(obj) and content_model == Project.__name__.lower(): return "news" diff --git a/feed/tests/test_feed_api.py b/feed/tests/test_feed_api.py index 12cbf199..0ab0e664 100644 --- a/feed/tests/test_feed_api.py +++ b/feed/tests/test_feed_api.py @@ -43,18 +43,15 @@ def test_feed_returns_project_news_as_news_content(self): self.assertEqual(item["content"]["id"], news.id) self.assertEqual(item["content"]["text"], "Project feed news") - def test_feed_returns_program_news_as_news_content(self): + def test_feed_ignores_program_news_filter(self): program = create_partner_program(name="Feed program") - news = create_news_for(program, text="Program feed news") + create_news_for(program, text="Program feed news") + create_news_for(program, text="") response = self.client.get("/feed/?type=partnerprogram") self.assertEqual(response.status_code, 200) - item = response.data["results"][0] - self.assertEqual(set(item.keys()), {"type_model", "content"}) - self.assertEqual(item["type_model"], "news") - self.assertEqual(item["content"]["id"], news.id) - self.assertEqual(item["content"]["text"], "Program feed news") + self.assertEqual(response.data["results"], []) def test_feed_returns_project_feed_record_as_project_content(self): project = create_project(name="Feed record project") @@ -80,7 +77,7 @@ def test_feed_returns_vacancy_feed_record_as_vacancy_content(self): self.assertEqual(item["content"]["id"], vacancy.id) self.assertEqual(item["content"]["role"], "Backend developer") - def test_feed_combines_all_supported_filters(self): + def test_feed_combines_supported_filters_and_ignores_program_news(self): project_news = create_news_for( create_project(name="Combined project news"), text="Combined project news", @@ -117,14 +114,11 @@ def test_feed_combines_all_supported_filters(self): items_by_text[project_news.text]["content"]["id"], project_news.id, ) - self.assertEqual( - items_by_text[program_news.text]["content"]["id"], - program_news.id, - ) self.assertEqual( items_by_text[user_news.text]["content"]["id"], user_news.id, ) + self.assertNotIn(program_news.text, items_by_text) self.assertIn(project.id, content_ids_by_type["project"]) self.assertIn(vacancy.id, content_ids_by_type["vacancy"]) diff --git a/feed/views.py b/feed/views.py index 324b8a29..a02f628a 100644 --- a/feed/views.py +++ b/feed/views.py @@ -21,7 +21,11 @@ def _get_filter_data(self) -> list[str]: news_types: list[str] = filter_queries.split("|") if "news" in news_types: news_types.append("customuser") - return news_types + return [ + news_type + for news_type in news_types + if news_type != "partnerprogram" + ] def get_queryset(self) -> QuerySet[News]: filters = self._get_filter_data() diff --git a/news/tests/test_news_user_program_api.py b/news/tests/test_news_user_program_api.py index 2347f9c0..204cff58 100644 --- a/news/tests/test_news_user_program_api.py +++ b/news/tests/test_news_user_program_api.py @@ -97,6 +97,15 @@ def test_program_news_list_orders_pinned_news_first(self): news_ids = [item["id"] for item in response.data["results"]] self.assertEqual(news_ids, [pinned_news.id, regular_news.id]) + def test_program_news_list_returns_program_news(self): + news = create_news_for(self.program, text="Visible program news") + + response = self.client.get(f"/programs/{self.program.id}/news/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["results"][0]["id"], news.id) + self.assertEqual(response.data["results"][0]["text"], "Visible program news") + def test_missing_program_context_returns_not_found(self): response = self.client.get("/programs/999999/news/") From 3ed5393a37c5178b39abcd532e354673b3e3233f Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 5 Jun 2026 12:55:30 +0500 Subject: [PATCH 23/32] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20Events=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/events.md | 170 +++++++++++++++++++++- events/tests.py | 95 ------------ events/tests/__init__.py | 0 events/tests/helpers.py | 64 ++++++++ events/tests/test_events_api.py | 138 ++++++++++++++++++ events/tests/test_likes.py | 35 +++++ events/tests/test_registered_api.py | 39 +++++ events/tests/test_telegram_autoposting.py | 95 ++++++++++++ events/views.py | 5 +- 9 files changed, 543 insertions(+), 98 deletions(-) delete mode 100644 events/tests.py create mode 100644 events/tests/__init__.py create mode 100644 events/tests/helpers.py create mode 100644 events/tests/test_events_api.py create mode 100644 events/tests/test_likes.py create mode 100644 events/tests/test_registered_api.py create mode 100644 events/tests/test_telegram_autoposting.py diff --git a/docs/modules/events.md b/docs/modules/events.md index 48df748a..d2e50f68 100644 --- a/docs/modules/events.md +++ b/docs/modules/events.md @@ -1,3 +1,171 @@ # Events -TODO +## Назначение + +Events отвечает за отложенную функциональность мероприятий Procollab: список +мероприятий, карточку мероприятия, тип проведения, теги, зарегистрированных +пользователей и опциональную служебную автопубликацию в Telegram. + +## Статус модуля + +Модуль разрабатывался под задачу, которая перестала быть актуальной в процессе +работы. Сейчас он не является активным продуктовым направлением, но его базовый +read/admin-контур поддерживается и покрыт regression-тестами. + +Часть возможностей заложена в модели, но не имеет публичных endpoints. Это +технический долг, который нужно решать только при возвращении мероприятий в +активную продуктовую работу. + +Текущие тесты покрывают основные API-flow, модель лайков, registered users, +Telegram helpers и autoposting signal. Coverage по модулю без tests/migrations +составляет около 98%. + +## Основные возможности + +- просмотр списка мероприятий; +- просмотр карточки мероприятия; +- создание, обновление и удаление мероприятия staff-пользователем; +- фильтрация мероприятий по title, text и дате создания; +- хранение тегов мероприятия через `django-taggit`; +- хранение зарегистрированных пользователей мероприятия; +- получение списка зарегистрированных пользователей; +- получение списка типов мероприятий; +- опциональная автопубликация мероприятия в Telegram при настроенных + `AUTOPOSTING_ON`, `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHANNEL`. + +Заложено в модели, но сейчас не имеет отдельного публичного API: + +- избранное через `favorites`; +- просмотры через `views`; +- лайки через `LikesOnEvent`; +- регистрация/отмена регистрации пользователя на мероприятие. + +## Архитектура + +- `events/models.py` - модели `Event` и `LikesOnEvent`. +- `events/views.py` - HTTP endpoints для списка, деталей, зарегистрированных + пользователей и типов мероприятий. +- `events/serializers.py` - request/response serializers для списка, деталей и + зарегистрированных пользователей. +- `events/filters.py` - фильтры списка мероприятий. +- `events/managers.py` - manager для лайков мероприятий. +- `events/helpers.py` - низкоуровневые вызовы Telegram API. +- `events/signals.py` - autoposting signal для Telegram. +- `events/admin.py` - настройка Django admin. +- `events/tests/` - regression-тесты модуля. + +## Основные сущности + +- `Event` - мероприятие. +- `LikesOnEvent` - лайк мероприятия пользователем. +- `favorites` - пользователи, добавившие мероприятие в избранное. +- `registered_users` - пользователи, зарегистрированные на мероприятие. +- `tags` - теги мероприятия. +- `tg_message_id` - ID сообщения в Telegram для дальнейшего редактирования. + +## API + +- `GET /events/` - список мероприятий. +- `POST /events/` - создание мероприятия staff-пользователем. +- `GET /events//` - детали мероприятия. +- `PUT /events//` - полное обновление мероприятия staff-пользователем. +- `PATCH /events//` - частичное обновление мероприятия staff-пользователем. +- `DELETE /events//` - удаление мероприятия staff-пользователем. +- `GET /events//registered/` - список зарегистрированных пользователей. +- `GET /events/types/` - список типов мероприятий. + +Связанный endpoint: + +- `GET /auth/users/current/events/` - мероприятия, на которые зарегистрирован + текущий пользователь. + +## Основные сценарии + +### 1. Просмотр мероприятий + +Пользователь открывает список или карточку мероприятия. Read endpoints доступны +без staff-прав, потому что используется permission `IsStaffOrReadOnly`. + +Список мероприятий можно фильтровать по: + +- `title__contains`; +- `text__contains`; +- `datetime_created__gt`. + +### 2. Управление мероприятиями + +Создание, обновление и удаление мероприятия доступны только staff-пользователям. + +При создании и обновлении используются поля: + +- название; +- полный текст; +- короткое описание; +- обложка; +- дата проведения; +- тип мероприятия; +- сайт организатора; +- награда; +- теги; +- избранное. + +### 3. Зарегистрированные пользователи + +Связь пользователей с мероприятием хранится в `Event.registered_users`. + +Endpoint `/events//registered/` возвращает список зарегистрированных +пользователей через `RegisteredUserListSerializer`. + +Для текущего пользователя список его мероприятий доступен через +`/auth/users/current/events/`. + +### 4. Лайки, избранное и регистрация + +Модель поддерживает: + +- `favorites` как M2M между мероприятием и пользователем; +- `LikesOnEvent` как отдельную модель лайка с флагом `is_liked`. + +Отдельных публичных endpoints для toggle-like, favorite или регистрации на +мероприятие в `events/urls.py` сейчас нет. + +### 5. Telegram autoposting + +Если `AUTOPOSTING_ON = True`, при сохранении `Event` импортируются signals и +срабатывает автопубликация: + +- при создании отправляется новое сообщение в Telegram; +- при обновлении редактируется существующее сообщение по `tg_message_id`. + +В обычном локальном и тестовом режиме `AUTOPOSTING_ON` по умолчанию выключен, +поэтому внешние HTTP-запросы в Telegram не выполняются. + +## Ограничения и правила + +- Read endpoints доступны всем пользователям. +- Write endpoints доступны только staff-пользователям. +- `text`, `cover_url` и `datetime_of_event` обязательны при создании + мероприятия. +- `short_text` ограничен 256 символами. +- `tg_message_id` не редактируется вручную через API. +- Telegram autoposting зависит от `AUTOPOSTING_ON`, `TELEGRAM_BOT_TOKEN` и + `TELEGRAM_CHANNEL`. +- Интеграция с Telegram не должна выполняться без явно настроенных переменных + окружения. + +## Тесты + +Текущие тесты лежат в `events/tests/`. + +Они проверяют: + +- публичный список и detail мероприятия; +- создание, частичное обновление и удаление staff-пользователем; +- запрет write-действий обычному и анонимному пользователю; +- validation errors при пустом `text` и пустом payload; +- фильтры списка мероприятий; +- `/events/types/`; +- `/events//registered/`; +- `LikesOnEventManager`; +- `/auth/users/current/events/`; +- Telegram helpers и autoposting signal. diff --git a/events/tests.py b/events/tests.py deleted file mode 100644 index 83a3a6af..00000000 --- a/events/tests.py +++ /dev/null @@ -1,95 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIRequestFactory, force_authenticate -from tests.constants import USER_CREATE_DATA -from users.models import CustomUser -from users.views import UserList - -from events.models import Event -from events.views import EventDetail, EventsList - - -class EventsTestCase(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - - self.user_list_view = UserList.as_view() - - self.event_list_view = EventsList.as_view() - self.event_detail_view = EventDetail.as_view() - - self.TITLE = "TITLE" - self.TEXT = "TEXT" - self.SHORT_TEXT = "njknk" - self.COVER_URL = "https://example.com/" - self.DATETIME_OF_EVENT = "2023-03-11T14:31:22+03:00" - - self.CREATE_DATA = { - "title": self.TITLE, - "text": self.TEXT, - "short_text": self.SHORT_TEXT, - "cover_url": self.COVER_URL, - "datetime_of_event": self.DATETIME_OF_EVENT, - "tags": ["some tag"], - } - - def test_events_creation(self): - user = self._user_create(is_staff=True) - request = self.factory.post("events/", self.CREATE_DATA, format="json") - force_authenticate(request, user=user) - response = self.event_list_view(request) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data["title"], self.TITLE) - self.assertEqual(response.data["short_text"], self.SHORT_TEXT) - self.assertEqual(response.data["cover_url"], self.COVER_URL) - self.assertEqual(response.data["datetime_of_event"], self.DATETIME_OF_EVENT) - - def test_events_creation_by_not_staff_user(self): - user = self._user_create(is_staff=False) - request = self.factory.post("events/", self.CREATE_DATA) - force_authenticate(request, user=user) - response = self.event_list_view(request) - self.assertEqual(response.status_code, 403) - - def test_event_creation_with_empty_text(self): - user = self._user_create(is_staff=True) - new_data = self.CREATE_DATA - new_data["text"] = "" - - request = self.factory.post("events/", new_data) - force_authenticate(request, user=user) - response = self.event_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_events_update(self): - user = self._user_create(is_staff=True) - request = self.factory.post("events/", self.CREATE_DATA) - force_authenticate(request, user=user) - response = self.event_list_view(request) - event_id = response.data["id"] - event = Event.objects.get(id=event_id) - - request = self.factory.patch(f"event/{event.pk}/", {"text": "New text"}) - force_authenticate(request, user=user) - response = self.event_detail_view(request, pk=event.pk) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["text"], "New text") - - def test_event_creation_with_empty_data(self): - user = self._user_create(is_staff=True) - request = self.factory.post("events/", {}) - - force_authenticate(request, user=user) - response = self.event_list_view(request) - self.assertEqual(response.status_code, 400) - - def _user_create(self, is_staff=False): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - user.is_staff = is_staff - user.is_active = True - user.save() - return user diff --git a/events/tests/__init__.py b/events/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/events/tests/helpers.py b/events/tests/helpers.py new file mode 100644 index 00000000..1a06993e --- /dev/null +++ b/events/tests/helpers.py @@ -0,0 +1,64 @@ +from datetime import date, timedelta +from uuid import uuid4 + +from django.utils import timezone + +from events.models import Event +from users.models import CustomUser + + +def build_user( + *, + prefix: str = "events-user", + is_staff: bool = False, + is_active: bool = True, +) -> CustomUser: + user = CustomUser.objects.create_user( + email=f"{prefix}-{uuid4().hex}@example.com", + password="very_strong_password", + first_name="Иван", + last_name="Иванов", + birthday=date(2000, 1, 1), + ) + user.is_staff = is_staff + user.is_active = is_active + user.save(update_fields=["is_staff", "is_active"]) + return user + + +def build_staff_user(*, prefix: str = "events-staff") -> CustomUser: + return build_user(prefix=prefix, is_staff=True) + + +def build_event_payload(**overrides) -> dict: + payload = { + "title": "Мероприятие", + "text": "Полное описание мероприятия", + "short_text": "Краткое описание", + "cover_url": "https://example.com/event-cover.jpg", + "datetime_of_event": ( + timezone.now() + timedelta(days=7) + ).isoformat(), + "tags": ["events", "procollab"], + "event_type": 1, + "prize": "Диплом участника", + } + payload.update(overrides) + return payload + + +def build_event(**overrides) -> Event: + tags = overrides.pop("tags", ["events"]) + defaults = { + "title": "Мероприятие", + "text": "Полное описание мероприятия", + "short_text": "Краткое описание", + "cover_url": "https://example.com/event-cover.jpg", + "datetime_of_event": timezone.now() + timedelta(days=7), + "event_type": 1, + "prize": "Диплом участника", + } + defaults.update(overrides) + event = Event.objects.create(**defaults) + event.tags.set(tags) + return event diff --git a/events/tests/test_events_api.py b/events/tests/test_events_api.py new file mode 100644 index 00000000..6ed4b00e --- /dev/null +++ b/events/tests/test_events_api.py @@ -0,0 +1,138 @@ +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from events.models import Event +from events.tests.helpers import ( + build_event, + build_event_payload, + build_staff_user, + build_user, +) + + +class EventReadAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_list_events_available_without_auth(self): + event = build_event(title="Открытое мероприятие") + + response = self.client.get("/events/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["id"], event.id) + self.assertEqual(response.data[0]["title"], "Открытое мероприятие") + + def test_detail_event_available_without_auth(self): + event = build_event(title="Детальная карточка") + + response = self.client.get(f"/events/{event.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], event.id) + self.assertEqual(response.data["title"], "Детальная карточка") + self.assertEqual(response.data["text"], event.text) + + def test_event_types_available_without_auth(self): + response = self.client.get("/events/types/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + ((0, "Оффлайн"), (1, "Онлайн"), (2, "Оффлайн и онлайн")), + ) + + def test_list_events_can_be_filtered_by_title_text_and_created_date(self): + old_event = build_event(title="Старое", text="Архивная встреча") + Event.objects.filter(pk=old_event.pk).update( + datetime_created=timezone.now() - timedelta(days=30) + ) + fresh_event = build_event(title="Новый запуск", text="Demo day") + + title_response = self.client.get("/events/", {"title__contains": "Новый"}) + text_response = self.client.get("/events/", {"text__contains": "Demo"}) + date_response = self.client.get( + "/events/", + {"datetime_created__gt": (timezone.now() - timedelta(days=1)).isoformat()}, + ) + + self.assertEqual([item["id"] for item in title_response.data], [fresh_event.id]) + self.assertEqual([item["id"] for item in text_response.data], [fresh_event.id]) + self.assertEqual([item["id"] for item in date_response.data], [fresh_event.id]) + + +class EventMutationAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_staff_user_can_create_event(self): + user = build_staff_user() + payload = build_event_payload(title="Создано staff-пользователем") + self.client.force_authenticate(user) + + response = self.client.post("/events/", payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["title"], "Создано staff-пользователем") + self.assertTrue(Event.objects.filter(title=payload["title"]).exists()) + + def test_non_staff_user_cannot_create_event(self): + user = build_user() + self.client.force_authenticate(user) + + response = self.client.post("/events/", build_event_payload(), format="json") + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_anonymous_user_cannot_create_event(self): + response = self.client.post("/events/", build_event_payload(), format="json") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_staff_user_can_patch_event(self): + user = build_staff_user() + event = build_event(text="Старый текст") + self.client.force_authenticate(user) + + response = self.client.patch( + f"/events/{event.id}/", + {"text": "Новый текст"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["text"], "Новый текст") + + def test_staff_user_can_delete_event(self): + user = build_staff_user() + event = build_event() + self.client.force_authenticate(user) + + response = self.client.delete(f"/events/{event.id}/") + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Event.objects.filter(pk=event.pk).exists()) + + def test_create_event_rejects_empty_text(self): + user = build_staff_user() + self.client.force_authenticate(user) + + response = self.client.post( + "/events/", + build_event_payload(text=""), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_event_rejects_empty_payload(self): + user = build_staff_user() + self.client.force_authenticate(user) + + response = self.client.post("/events/", {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/events/tests/test_likes.py b/events/tests/test_likes.py new file mode 100644 index 00000000..f2459c2f --- /dev/null +++ b/events/tests/test_likes.py @@ -0,0 +1,35 @@ +from django.test import TestCase + +from events.models import LikesOnEvent +from events.tests.helpers import build_event, build_user + + +class LikesOnEventManagerTests(TestCase): + def test_toggle_like_creates_active_like(self): + user = build_user() + event = build_event() + + like = LikesOnEvent.objects.toggle_like(user, event) + + self.assertEqual(like.user, user) + self.assertEqual(like.event, event) + self.assertTrue(like.is_liked) + + def test_toggle_like_switches_existing_like(self): + user = build_user() + event = build_event() + LikesOnEvent.objects.toggle_like(user, event) + + like = LikesOnEvent.objects.toggle_like(user, event) + + self.assertFalse(like.is_liked) + self.assertEqual(LikesOnEvent.objects.count(), 1) + + def test_get_likes_for_list_view_returns_expected_queryset(self): + user = build_user() + event = build_event() + like = LikesOnEvent.objects.toggle_like(user, event) + + likes = list(LikesOnEvent.objects.get_likes_for_list_view()) + + self.assertEqual(likes, [like]) diff --git a/events/tests/test_registered_api.py b/events/tests/test_registered_api.py new file mode 100644 index 00000000..98d55803 --- /dev/null +++ b/events/tests/test_registered_api.py @@ -0,0 +1,39 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from events.tests.helpers import build_event, build_user + + +class EventRegisteredUsersAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_registered_users_endpoint_returns_event_users(self): + event = build_event() + registered_user = build_user(prefix="registered") + outsider = build_user(prefix="outsider") + event.registered_users.add(registered_user) + + response = self.client.get(f"/events/{event.id}/registered/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [registered_user.id]) + self.assertNotIn(outsider.id, [item["id"] for item in response.data]) + + def test_registered_users_endpoint_returns_404_for_missing_event(self): + response = self.client.get("/events/999999/registered/") + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_current_user_events_returns_only_registered_events(self): + user = build_user(prefix="current") + registered_event = build_event(title="Моё мероприятие") + build_event(title="Чужое мероприятие") + registered_event.registered_users.add(user) + self.client.force_authenticate(user) + + response = self.client.get("/auth/users/current/events/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [registered_event.id]) diff --git a/events/tests/test_telegram_autoposting.py b/events/tests/test_telegram_autoposting.py new file mode 100644 index 00000000..9884d278 --- /dev/null +++ b/events/tests/test_telegram_autoposting.py @@ -0,0 +1,95 @@ +from unittest.mock import Mock, patch + +from django.test import TestCase, override_settings + +from events.helpers import edit_message, send_message +from events.models import Event +from events.tests.helpers import build_event + + +class TelegramHelpersTests(TestCase): + @override_settings(TELEGRAM_BOT_TOKEN="bot-token") + @patch("events.helpers.requests.post") + def test_send_message_uses_telegram_bot_api(self, post_mock): + post_mock.return_value = Mock(json=Mock(return_value={"ok": True})) + + response = send_message("Текст", "@channel") + + self.assertEqual(response, {"ok": True}) + post_mock.assert_called_once_with( + "https://api.telegram.org/botbot-token/sendMessage", + data={ + "chat_id": "@channel", + "text": "Текст", + "parse_mode": "markdown", + }, + ) + + @override_settings(TELEGRAM_BOT_TOKEN="bot-token") + @patch("events.helpers.requests.post") + def test_edit_message_uses_telegram_bot_api(self, post_mock): + post_mock.return_value = Mock(json=Mock(return_value={"ok": True})) + + response = edit_message("Новый текст", 123, "@channel") + + self.assertEqual(response, {"ok": True}) + post_mock.assert_called_once_with( + "https://api.telegram.org/botbot-token/editMessageText", + data={ + "chat_id": "@channel", + "text": "Новый текст", + "parse_mode": "markdown", + "message_id": 123, + }, + ) + + +class EventAutopostingSignalTests(TestCase): + def test_created_event_sends_telegram_message_and_stores_message_id(self): + event = build_event(title="Telegram event", short_text="Short") + from events.signals import autoposting_receiver + + with override_settings(AUTOPOSTING_ON=True, TELEGRAM_CHANNEL="@channel"): + with patch( + "events.signals.send_message", + return_value={"ok": True, "result": {"message_id": 123}}, + ) as send_mock, patch("events.signals.edit_message"): + autoposting_receiver(Event, event, True) + + event.refresh_from_db() + self.assertEqual(event.tg_message_id, 123) + send_mock.assert_called_once_with( + "***Telegram event***\nShort\n\nhttps://procollab.ru/events/" + f"{event.pk}", + "@channel", + ) + + def test_updated_event_edits_existing_telegram_message(self): + event = build_event(title="Updated event", short_text="New short") + event.tg_message_id = 456 + event.save(update_fields=["tg_message_id"]) + from events.signals import autoposting_receiver + + with override_settings(AUTOPOSTING_ON=True, TELEGRAM_CHANNEL="@channel"): + with patch("events.signals.edit_message") as edit_mock: + autoposting_receiver(Event, event, False) + + edit_mock.assert_called_once_with( + "***Updated event***\nNew short\n\nhttps://procollab.ru/events/" + f"{event.pk}", + 456, + "@channel", + ) + + def test_autoposting_disabled_does_not_call_telegram(self): + event = build_event() + from events.signals import autoposting_receiver + + with override_settings(AUTOPOSTING_ON=False): + with patch("events.signals.send_message") as send_mock, patch( + "events.signals.edit_message" + ) as edit_mock: + autoposting_receiver(Event, event, True) + + send_mock.assert_not_called() + edit_mock.assert_not_called() diff --git a/events/views.py b/events/views.py index bcca7a8c..3c75ce12 100644 --- a/events/views.py +++ b/events/views.py @@ -1,3 +1,4 @@ +from django.shortcuts import get_object_or_404 from django_filters import rest_framework as filters from rest_framework import generics from rest_framework.permissions import AllowAny @@ -34,8 +35,8 @@ class EventRegisteredUsersList(generics.ListAPIView): permission_classes = [IsStaffOrReadOnly] def get_queryset(self): - users = Event.objects.get(pk=self.kwargs["id"]).registered_users.all() - return users + event = get_object_or_404(Event, pk=self.kwargs["id"]) + return event.registered_users.all() class EventTypes(APIView): From 64604d87a95a5c21ffc7a1b271f5efaf1d2bf120 Mon Sep 17 00:00:00 2001 From: Toksi Date: Fri, 5 Jun 2026 13:04:56 +0500 Subject: [PATCH 24/32] =?UTF-8?q?=D0=9E=D1=82=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20URL=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=20Eve?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/events.md | 92 ++++++++----------- events/tests/test_events_api.py | 138 ---------------------------- events/tests/test_public_urls.py | 25 +++++ events/tests/test_registered_api.py | 28 ++---- events/urls.py | 2 + procollab/urls.py | 4 +- 6 files changed, 77 insertions(+), 212 deletions(-) delete mode 100644 events/tests/test_events_api.py create mode 100644 events/tests/test_public_urls.py diff --git a/docs/modules/events.md b/docs/modules/events.md index d2e50f68..7018328d 100644 --- a/docs/modules/events.md +++ b/docs/modules/events.md @@ -2,39 +2,43 @@ ## Назначение -Events отвечает за отложенную функциональность мероприятий Procollab: список -мероприятий, карточку мероприятия, тип проведения, теги, зарегистрированных -пользователей и опциональную служебную автопубликацию в Telegram. +Events отвечает за отложенную функциональность мероприятий Procollab. Модуль +сохраняет модели, admin-управление, связанный список мероприятий пользователя и +опциональную служебную автопубликацию в Telegram, но публичные endpoints +`/events/` сейчас не подключены в корневом URLConf. ## Статус модуля Модуль разрабатывался под задачу, которая перестала быть актуальной в процессе -работы. Сейчас он не является активным продуктовым направлением, но его базовый -read/admin-контур поддерживается и покрыт regression-тестами. +работы. Сейчас он не является активным продуктовым направлением. Чтобы +неиспользуемые ручки не торчали наружу и не попадали в Swagger, `events.urls` +не подключается в `procollab/urls.py`. Часть возможностей заложена в модели, но не имеет публичных endpoints. Это технический долг, который нужно решать только при возвращении мероприятий в активную продуктовую работу. -Текущие тесты покрывают основные API-flow, модель лайков, registered users, -Telegram helpers и autoposting signal. Coverage по модулю без tests/migrations -составляет около 98%. +Текущие тесты покрывают отключение публичных `/events/` endpoints, связанный +endpoint пользователя, модель лайков, Telegram helpers и autoposting signal. ## Основные возможности -- просмотр списка мероприятий; -- просмотр карточки мероприятия; -- создание, обновление и удаление мероприятия staff-пользователем; -- фильтрация мероприятий по title, text и дате создания; +- хранение мероприятий в базе; +- управление мероприятиями через Django admin; - хранение тегов мероприятия через `django-taggit`; - хранение зарегистрированных пользователей мероприятия; -- получение списка зарегистрированных пользователей; -- получение списка типов мероприятий; +- получение списка мероприятий текущего пользователя через users endpoint; - опциональная автопубликация мероприятия в Telegram при настроенных `AUTOPOSTING_ON`, `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHANNEL`. Заложено в модели, но сейчас не имеет отдельного публичного API: +- список мероприятий; +- карточка мероприятия; +- создание, обновление и удаление мероприятия через `/events/`; +- фильтрация мероприятий; +- список зарегистрированных пользователей мероприятия; +- список типов мероприятий; - избранное через `favorites`; - просмотры через `views`; - лайки через `LikesOnEvent`; @@ -43,8 +47,8 @@ Telegram helpers и autoposting signal. Coverage по модулю без tests/ ## Архитектура - `events/models.py` - модели `Event` и `LikesOnEvent`. -- `events/views.py` - HTTP endpoints для списка, деталей, зарегистрированных - пользователей и типов мероприятий. +- `events/views.py` - неактивные HTTP endpoints для списка, деталей, + зарегистрированных пользователей и типов мероприятий. - `events/serializers.py` - request/response serializers для списка, деталей и зарегистрированных пользователей. - `events/filters.py` - фильтры списка мероприятий. @@ -52,7 +56,8 @@ Telegram helpers и autoposting signal. Coverage по модулю без tests/ - `events/helpers.py` - низкоуровневые вызовы Telegram API. - `events/signals.py` - autoposting signal для Telegram. - `events/admin.py` - настройка Django admin. -- `events/tests/` - regression-тесты модуля. +- `events/tests/` - regression-тесты отключенных публичных URL, связанного users + endpoint, модели лайков и Telegram-интеграции. ## Основные сущности @@ -65,36 +70,25 @@ Telegram helpers и autoposting signal. Coverage по модулю без tests/ ## API -- `GET /events/` - список мероприятий. -- `POST /events/` - создание мероприятия staff-пользователем. -- `GET /events//` - детали мероприятия. -- `PUT /events//` - полное обновление мероприятия staff-пользователем. -- `PATCH /events//` - частичное обновление мероприятия staff-пользователем. -- `DELETE /events//` - удаление мероприятия staff-пользователем. -- `GET /events//registered/` - список зарегистрированных пользователей. -- `GET /events/types/` - список типов мероприятий. +Публичные endpoints модуля Events сейчас отключены на уровне +`procollab/urls.py`. Эти URLs не должны быть доступны извне: -Связанный endpoint: +- `/events/`; +- `/events//`; +- `/events//registered/`; +- `/events/types/`. + +Активный связанный endpoint: - `GET /auth/users/current/events/` - мероприятия, на которые зарегистрирован текущий пользователь. ## Основные сценарии -### 1. Просмотр мероприятий - -Пользователь открывает список или карточку мероприятия. Read endpoints доступны -без staff-прав, потому что используется permission `IsStaffOrReadOnly`. - -Список мероприятий можно фильтровать по: +### 1. Управление мероприятиями -- `title__contains`; -- `text__contains`; -- `datetime_created__gt`. - -### 2. Управление мероприятиями - -Создание, обновление и удаление мероприятия доступны только staff-пользователям. +Пока публичный API отключен, создание, обновление и удаление мероприятий +остаются admin-сценарием. При создании и обновлении используются поля: @@ -109,17 +103,14 @@ Telegram helpers и autoposting signal. Coverage по модулю без tests/ - теги; - избранное. -### 3. Зарегистрированные пользователи +### 2. Зарегистрированные пользователи Связь пользователей с мероприятием хранится в `Event.registered_users`. -Endpoint `/events//registered/` возвращает список зарегистрированных -пользователей через `RegisteredUserListSerializer`. - Для текущего пользователя список его мероприятий доступен через `/auth/users/current/events/`. -### 4. Лайки, избранное и регистрация +### 3. Лайки, избранное и регистрация Модель поддерживает: @@ -129,7 +120,7 @@ Endpoint `/events//registered/` возвращает список зарег Отдельных публичных endpoints для toggle-like, favorite или регистрации на мероприятие в `events/urls.py` сейчас нет. -### 5. Telegram autoposting +### 4. Telegram autoposting Если `AUTOPOSTING_ON = True`, при сохранении `Event` импортируются signals и срабатывает автопубликация: @@ -142,8 +133,7 @@ Endpoint `/events//registered/` возвращает список зарег ## Ограничения и правила -- Read endpoints доступны всем пользователям. -- Write endpoints доступны только staff-пользователям. +- `/events/` endpoints не подключены в корневом URLConf. - `text`, `cover_url` и `datetime_of_event` обязательны при создании мероприятия. - `short_text` ограничен 256 символами. @@ -159,13 +149,7 @@ Endpoint `/events//registered/` возвращает список зарег Они проверяют: -- публичный список и detail мероприятия; -- создание, частичное обновление и удаление staff-пользователем; -- запрет write-действий обычному и анонимному пользователю; -- validation errors при пустом `text` и пустом payload; -- фильтры списка мероприятий; -- `/events/types/`; -- `/events//registered/`; +- недоступность публичных `/events/` URLs; - `LikesOnEventManager`; - `/auth/users/current/events/`; - Telegram helpers и autoposting signal. diff --git a/events/tests/test_events_api.py b/events/tests/test_events_api.py deleted file mode 100644 index 6ed4b00e..00000000 --- a/events/tests/test_events_api.py +++ /dev/null @@ -1,138 +0,0 @@ -from datetime import timedelta - -from django.test import TestCase -from django.utils import timezone -from rest_framework import status -from rest_framework.test import APIClient - -from events.models import Event -from events.tests.helpers import ( - build_event, - build_event_payload, - build_staff_user, - build_user, -) - - -class EventReadAPITests(TestCase): - def setUp(self): - self.client = APIClient() - - def test_list_events_available_without_auth(self): - event = build_event(title="Открытое мероприятие") - - response = self.client.get("/events/") - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data[0]["id"], event.id) - self.assertEqual(response.data[0]["title"], "Открытое мероприятие") - - def test_detail_event_available_without_auth(self): - event = build_event(title="Детальная карточка") - - response = self.client.get(f"/events/{event.id}/") - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["id"], event.id) - self.assertEqual(response.data["title"], "Детальная карточка") - self.assertEqual(response.data["text"], event.text) - - def test_event_types_available_without_auth(self): - response = self.client.get("/events/types/") - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, - ((0, "Оффлайн"), (1, "Онлайн"), (2, "Оффлайн и онлайн")), - ) - - def test_list_events_can_be_filtered_by_title_text_and_created_date(self): - old_event = build_event(title="Старое", text="Архивная встреча") - Event.objects.filter(pk=old_event.pk).update( - datetime_created=timezone.now() - timedelta(days=30) - ) - fresh_event = build_event(title="Новый запуск", text="Demo day") - - title_response = self.client.get("/events/", {"title__contains": "Новый"}) - text_response = self.client.get("/events/", {"text__contains": "Demo"}) - date_response = self.client.get( - "/events/", - {"datetime_created__gt": (timezone.now() - timedelta(days=1)).isoformat()}, - ) - - self.assertEqual([item["id"] for item in title_response.data], [fresh_event.id]) - self.assertEqual([item["id"] for item in text_response.data], [fresh_event.id]) - self.assertEqual([item["id"] for item in date_response.data], [fresh_event.id]) - - -class EventMutationAPITests(TestCase): - def setUp(self): - self.client = APIClient() - - def test_staff_user_can_create_event(self): - user = build_staff_user() - payload = build_event_payload(title="Создано staff-пользователем") - self.client.force_authenticate(user) - - response = self.client.post("/events/", payload, format="json") - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.data["title"], "Создано staff-пользователем") - self.assertTrue(Event.objects.filter(title=payload["title"]).exists()) - - def test_non_staff_user_cannot_create_event(self): - user = build_user() - self.client.force_authenticate(user) - - response = self.client.post("/events/", build_event_payload(), format="json") - - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_anonymous_user_cannot_create_event(self): - response = self.client.post("/events/", build_event_payload(), format="json") - - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_staff_user_can_patch_event(self): - user = build_staff_user() - event = build_event(text="Старый текст") - self.client.force_authenticate(user) - - response = self.client.patch( - f"/events/{event.id}/", - {"text": "Новый текст"}, - format="json", - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["text"], "Новый текст") - - def test_staff_user_can_delete_event(self): - user = build_staff_user() - event = build_event() - self.client.force_authenticate(user) - - response = self.client.delete(f"/events/{event.id}/") - - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertFalse(Event.objects.filter(pk=event.pk).exists()) - - def test_create_event_rejects_empty_text(self): - user = build_staff_user() - self.client.force_authenticate(user) - - response = self.client.post( - "/events/", - build_event_payload(text=""), - format="json", - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_event_rejects_empty_payload(self): - user = build_staff_user() - self.client.force_authenticate(user) - - response = self.client.post("/events/", {}, format="json") - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/events/tests/test_public_urls.py b/events/tests/test_public_urls.py new file mode 100644 index 00000000..83272fce --- /dev/null +++ b/events/tests/test_public_urls.py @@ -0,0 +1,25 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from events.tests.helpers import build_event + + +class EventsPublicURLTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_events_public_urls_are_not_mounted(self): + event = build_event() + + urls = [ + "/events/", + f"/events/{event.id}/", + f"/events/{event.id}/registered/", + "/events/types/", + ] + + for url in urls: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/events/tests/test_registered_api.py b/events/tests/test_registered_api.py index 98d55803..a859b557 100644 --- a/events/tests/test_registered_api.py +++ b/events/tests/test_registered_api.py @@ -5,35 +5,25 @@ from events.tests.helpers import build_event, build_user -class EventRegisteredUsersAPITests(TestCase): +class UserRegisteredEventsAPITests(TestCase): def setUp(self): self.client = APIClient() - def test_registered_users_endpoint_returns_event_users(self): - event = build_event() - registered_user = build_user(prefix="registered") - outsider = build_user(prefix="outsider") - event.registered_users.add(registered_user) - - response = self.client.get(f"/events/{event.id}/registered/") - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual([item["id"] for item in response.data], [registered_user.id]) - self.assertNotIn(outsider.id, [item["id"] for item in response.data]) - - def test_registered_users_endpoint_returns_404_for_missing_event(self): - response = self.client.get("/events/999999/registered/") - - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_current_user_events_returns_only_registered_events(self): user = build_user(prefix="current") + outsider = build_user(prefix="outsider") registered_event = build_event(title="Моё мероприятие") build_event(title="Чужое мероприятие") registered_event.registered_users.add(user) - self.client.force_authenticate(user) + self.client.force_authenticate(user) response = self.client.get("/auth/users/current/events/") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual([item["id"] for item in response.data], [registered_event.id]) + + self.client.force_authenticate(outsider) + outsider_response = self.client.get("/auth/users/current/events/") + + self.assertEqual(outsider_response.status_code, status.HTTP_200_OK) + self.assertEqual(outsider_response.data, []) diff --git a/events/urls.py b/events/urls.py index a5b0c90f..8e005c90 100644 --- a/events/urls.py +++ b/events/urls.py @@ -4,6 +4,8 @@ app_name = "events" +# Файл сохранен для возможного возврата модуля мероприятий, но сейчас не +# подключен в `procollab/urls.py`, поэтому /events/ endpoints недоступны извне. urlpatterns = [ path("", EventsList.as_view()), path("/", EventDetail.as_view()), diff --git a/procollab/urls.py b/procollab/urls.py index b0b75eea..730d8143 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -51,7 +51,9 @@ path("invites/", include("invites.urls", namespace="invites")), path("auth/", include(("users.urls", "users"), namespace="users")), path("chats/", include("chats.urls", namespace="chats")), - path("events/", include("events.urls", namespace="events")), + # Модуль мероприятий сейчас отложен, поэтому публичные /events/ endpoints + # отключены. Для возврата модуля раскомментировать строку ниже. + # 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"))), From 19f4268480242eef0f8c6510f5f843840f63bc40 Mon Sep 17 00:00:00 2001 From: Toksi Date: Sat, 6 Jun 2026 00:24:15 +0500 Subject: [PATCH 25/32] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?flow=20=D0=BE=D1=82=D0=BA=D0=BB=D0=B8=D0=BA=D0=BE=D0=B2=20Vacan?= =?UTF-8?q?cy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/vacancy.md | 255 +++++++++++++++++++- vacancy/managers.py | 5 +- vacancy/tasks.py | 5 +- vacancy/tests.py | 108 --------- vacancy/tests/__init__.py | 0 vacancy/tests/helpers.py | 133 ++++++++++ vacancy/tests/test_models_services.py | 71 ++++++ vacancy/tests/test_permissions.py | 64 +++++ vacancy/tests/test_tasks_mapping.py | 68 ++++++ vacancy/tests/test_vacancies_api.py | 180 ++++++++++++++ vacancy/tests/test_vacancy_responses_api.py | 200 +++++++++++++++ vacancy/views.py | 3 +- 12 files changed, 977 insertions(+), 115 deletions(-) delete mode 100644 vacancy/tests.py create mode 100644 vacancy/tests/__init__.py create mode 100644 vacancy/tests/helpers.py create mode 100644 vacancy/tests/test_models_services.py create mode 100644 vacancy/tests/test_permissions.py create mode 100644 vacancy/tests/test_tasks_mapping.py create mode 100644 vacancy/tests/test_vacancies_api.py create mode 100644 vacancy/tests/test_vacancy_responses_api.py diff --git a/docs/modules/vacancy.md b/docs/modules/vacancy.md index 5f396020..a9b2b416 100644 --- a/docs/modules/vacancy.md +++ b/docs/modules/vacancy.md @@ -1,3 +1,256 @@ # Vacancy -TODO +## Назначение + +Vacancy отвечает за вакансии внутри проектов Procollab: публикацию ролей в +команду проекта, список вакансий, отклики пользователей, принятие или отклонение +отклика, автоматическое закрытие старых вакансий и email-уведомления по +ключевым событиям. + +## Статус модуля + +Модуль используется в продуктовых сценариях проектов, ленты и откликов, но +находится в состоянии технического долга. Основная бизнес-логика все еще +сосредоточена во `views.py` и serializers. + +Критичные API-flow закрыты regression-тестами: создание вакансии, фильтрация +списка, отклик, повторный отклик, accept/decline, закрытие вакансии, +email-уведомления и permissions. Текущий coverage по модулю без tests/migrations +составляет около 90%. + +## Основные возможности + +- создание вакансии лидером проекта; +- просмотр списка вакансий; +- просмотр, обновление и удаление вакансии; +- фильтрация вакансий; +- хранение требуемых навыков через `SkillToObject`; +- хранение параметров вакансии: опыт, график, формат работы, зарплата; +- отклик пользователя на активную вакансию; +- прикрепление сопроводительного файла к отклику; +- просмотр откликов по вакансии; +- просмотр своих откликов; +- принятие или отклонение отклика лидером проекта; +- автоматическое добавление принятого пользователя в collaborators проекта; +- автоматическое закрытие вакансии после принятия отклика; +- автоматическое закрытие старых активных вакансий celery-задачей; +- создание и удаление служебных feed-записей для активных вакансий; +- email-уведомления по событиям отклика. + +## Архитектура + +- `vacancy/models.py` - модели `Vacancy` и `VacancyResponse`. +- `vacancy/views.py` - API endpoints и основная orchestration logic. +- `vacancy/serializers.py` - request/response serializers, validation и часть + create/update logic. +- `vacancy/filters.py` - фильтры списка вакансий. +- `vacancy/managers.py` - queryset helpers для вакансий и откликов. +- `vacancy/services.py` - вспомогательная логика обновления навыков вакансии. +- `vacancy/tasks.py` - celery-задачи email-уведомлений и закрытия старых + вакансий. +- `vacancy/mapping.py` - шаблонные тексты, заголовки и ссылки email-сообщений. +- `vacancy/permissions.py` - object-level permissions для вакансий и откликов. +- `vacancy/admin.py` - Django admin и выгрузка email лидеров вакансий. +- `vacancy/tests/` - regression-тесты и helpers модуля. + +## Основные сущности + +- `Vacancy` - вакансия внутри проекта. +- `VacancyResponse` - отклик пользователя на вакансию. +- `required_skills` - generic-связь с `SkillToObject`. +- `accompanying_file` - сопроводительный `UserFile` у отклика. +- `is_active` - доступность вакансии для отклика и ленты. +- `datetime_closed` - дата закрытия вакансии. +- `is_approved` - статус отклика: `None` ожидает решения, `True` принят, + `False` отклонен. + +## API + +- `GET /vacancies/` - список вакансий. +- `POST /vacancies/` - создание вакансии. +- `GET /vacancies//` - детали вакансии. +- `PUT /vacancies//` - полное обновление вакансии. +- `PATCH /vacancies//` - частичное обновление вакансии. +- `DELETE /vacancies//` - удаление вакансии. +- `GET /vacancies//responses/` - список откликов на вакансию. +- `POST /vacancies//responses/` - отклик на вакансию. +- `GET /vacancies/responses//` - детали отклика. +- `PUT /vacancies/responses//` - обновление отклика. +- `PATCH /vacancies/responses//` - частичное обновление отклика. +- `DELETE /vacancies/responses//` - удаление отклика. +- `GET /vacancies/responses/self` - отклики текущего пользователя. +- `POST /vacancies/responses//accept/` - принять отклик. +- `POST /vacancies/responses//decline/` - отклонить отклик. + +Связанные endpoints и сценарии: + +- `GET /projects//responses/` - отклики по всем вакансиям проекта. +- `GET /projects/?any_vacancies=true` - проекты с активными вакансиями. +- `GET /feed/?type=vacancy` - служебные записи активных вакансий в ленте. + +## Основные сценарии + +### 1. Лидер создает вакансию + +Лидер проекта создает вакансию через `POST /vacancies/`. + +При создании: + +- проверяется, что текущий пользователь является лидером проекта; +- `required_skills_ids` преобразуются в `SkillToObject`; +- если проект опубликован, вакансия создается активной; +- если проект в draft, вакансия создается неактивной; +- активная вакансия через feed-signals попадает в ленту как служебная запись. + +### 2. Пользователь смотрит вакансии + +`GET /vacancies/` возвращает вакансии через `VacancyPagination`. + +По умолчанию список фильтруется по `is_active=True`, если параметр `is_active` +не передан явно. + +Доступные фильтры: + +- `project_id`; +- `is_active`; +- `required_experience`; +- `work_schedule`; +- `work_format`; +- `role_contains`; +- `salary_min`; +- `salary_max`. + +Queryset списка дополнительно ограничен вакансиями, созданными за последние 90 +дней. + +### 3. Пользователь откликается на вакансию + +Пользователь отправляет `POST /vacancies//responses/`. + +При отклике: + +- вакансия должна быть активной; +- пользователь подставляется из `request.user`; +- повторный отклик на ту же вакансию запрещен; +- можно передать `why_me`; +- можно приложить `accompanying_file`; +- лидеру проекта отправляется email о новом отклике. + +### 4. Лидер принимает отклик + +Лидер проекта вызывает `POST /vacancies/responses//accept/`. + +При успешном принятии: + +- отклик получает `is_approved=True`; +- пользователь добавляется в `Collaborator` проекта с ролью из вакансии; +- пользователю отправляется email; +- вакансия закрывается через `is_active=False`; +- `datetime_closed` обновляется автоматически в модели. + +### 5. Лидер отклоняет отклик + +Лидер проекта вызывает `POST /vacancies/responses//decline/`. + +При успешном отклонении: + +- отклик получает `is_approved=False`; +- отправляется email-уведомление. + +Повторно принять или отклонить уже обработанный отклик нельзя. + +### 6. Проект меняет draft-статус + +`projects.signals` синхронизирует активность вакансий с draft-статусом проекта: + +- при переводе проекта в draft вакансии становятся неактивными; +- при публикации проекта вакансии становятся активными; +- feed-записи вакансий создаются или удаляются в зависимости от активности. + +### 7. Вакансия попадает в ленту + +`feed.signals` создают служебную `News`-запись для активной вакансии и удаляют +ее при деактивации или удалении вакансии. + +Дополнительно `/feed/` фильтрует vacancy-записи и показывает только вакансии, +которые: + +- активны; +- принадлежат опубликованному проекту; +- принадлежат публичному проекту. + +### 8. Старые вакансии закрываются автоматически + +Celery-задача `email_notificate_vacancy_outdated()` выбирает активные вакансии +старше 30 дней, отправляет лидерам email и переводит вакансии в `is_active=False`. + +## Связи с другими модулями + +- `projects` - каждая вакансия принадлежит проекту; лидер проекта управляет + вакансией и откликами. +- `feed` - активные вакансии отображаются в ленте как служебные записи. +- `news` - служебные feed-записи физически хранятся как `News(text="")`. +- `files` - отклик может ссылаться на `UserFile`. +- `core` - навыки вакансии хранятся через `SkillToObject`. +- `users` - пользователь откликается на вакансию и может стать collaborator. +- `mailing` - отправка email идет через mailing utilities. +- `partner_programs` и `project_rates` - сейчас переиспользуют + `vacancy.tasks.send_email`, хотя это уже не только vacancy-логика. + +## Ограничения и риски + +- `vacancy/views.py` содержит много бизнес-логики: отклик, accept/decline, + закрытие вакансии, создание collaborator и отправка email. +- `vacancy/serializers.py` содержит не только contracts, но и create/update + orchestration. +- `send_email` находится в `vacancy.tasks`, но используется также + `partner_programs` и `project_rates`; это общий notification helper, который + нужно вынести ближе к `mailing`. +- `required_skills_ids` в serializer отмечен как необязательный, но create-flow + ожидает его наличие. +- `update_vacancy_skills()` может вернуть `Response`, но callers в `views.py` + этот результат не обрабатывают. +- `GET /vacancies//responses/` для несуществующей вакансии сейчас + возвращает пустой список, а не 404. +- `accompanying_file` ищется по всем `UserFile`, без явной проверки, что файл + принадлежит текущему пользователю. + +## Тесты + +Тесты лежат в `vacancy/tests/`. + +Текущие regression-тесты проверяют: + +- создание вакансии лидером проекта; +- запрет создания вакансии пользователем, который не является лидером проекта; +- создание неактивной вакансии для draft-проекта; +- список активных вакансий по умолчанию; +- явный запрос неактивных вакансий через `is_active=false`; +- фильтры списка вакансий: проект, роль, зарплата, опыт, график и формат работы; +- исключение из списка вакансий старше 90 дней; +- detail вакансии; +- закрытие вакансии лидером через `PUT` и отклонение ожидающих откликов; +- запрет обновления вакансии не-лидером; +- отклик пользователя на активную вакансию; +- запрет отклика на закрытую вакансию; +- запрет повторного отклика; +- список откликов вакансии; +- `/vacancies/responses/self`; +- detail отклика с полной информацией о файле; +- accept-flow: отклик принимается, пользователь становится collaborator, + вакансия остается закрытой, письмо отправляется пользователю; +- decline-flow: отклик отклоняется, письмо отправляется пользователю; +- запрет accept не-лидером; +- запрет повторного accept/decline; +- обновление `datetime_closed` при смене активности вакансии; +- замену `required_skills` через `update_vacancy_skills()`; +- контролируемую ошибку при передаче несуществующего навыка; +- `send_email()` и базовую email mapping logic; +- `email_notificate_vacancy_outdated()`; +- object-level permissions для вакансий и откликов. + +Пока не покрыты точечными тестами: + +- admin export email лидеров; +- запрет прикрепления чужого `UserFile` к отклику; +- контракт `GET /vacancies//responses/` для отсутствующей вакансии. diff --git a/vacancy/managers.py b/vacancy/managers.py index fe7e3fd9..7b371d1f 100644 --- a/vacancy/managers.py +++ b/vacancy/managers.py @@ -1,11 +1,12 @@ -import datetime +from datetime import timedelta from django.db.models import Manager +from django.utils import timezone class VacancyManager(Manager): def get_vacancy_for_list_view(self): - expiration_check = datetime.datetime.now() - datetime.timedelta(days=90) + expiration_check = timezone.now() - timedelta(days=90) return ( self.get_queryset() .select_related("project") diff --git a/vacancy/tasks.py b/vacancy/tasks.py index 9ca02605..f447eb35 100644 --- a/vacancy/tasks.py +++ b/vacancy/tasks.py @@ -1,5 +1,6 @@ -import datetime +from datetime import timedelta +from django.utils import timezone from mailing.typing import ContextDataDict, EmailDataToPrepare, MailDataDict from mailing.utils import prepare_mail_data, send_mass_mail from procollab.celery import app @@ -37,7 +38,7 @@ def send_email(data: EmailParamsType): @app.task def email_notificate_vacancy_outdated(): """Уведомление лидера по email о том, что вакансия просрочилась""" - expiration_check = datetime.datetime.now() - datetime.timedelta(days=30) + expiration_check = timezone.now() - timedelta(days=30) outdated_active_vacancies = Vacancy.objects.select_related( "project", "project__leader" diff --git a/vacancy/tests.py b/vacancy/tests.py deleted file mode 100644 index fb6d3596..00000000 --- a/vacancy/tests.py +++ /dev/null @@ -1,108 +0,0 @@ -from collections import OrderedDict - -from django.test import TestCase -from rest_framework.test import APIRequestFactory, force_authenticate - -from industries.models import Industry -from projects.models import Project -from tests.constants import USER_CREATE_DATA -from users.models import CustomUser -from users.views import UserList -from vacancy.constants import ( - WorkExperience, - WorkSchedule, - WorkFormat, -) -from vacancy.views import ( - VacancyList, - VacancyDetail, - VacancyResponseDetail, - VacancyResponseList, -) - - -class VacancyTestCase(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.user_list_view = UserList.as_view() - self.vacancy_list_view = VacancyList.as_view() - self.vacancy_detail_view = VacancyDetail.as_view() - self.user_project_owner = self.user_create() - - self.created_project = Project.objects.create( - name="Test", - description="Test", - industry=Industry.objects.create(name="Test"), - leader=self.user_project_owner, - ) - self.vacancy_create_data = { - "role": "Test", - "required_skills_ids": [1, 15], - "description": "Test", - "is_active": True, - "project": self.created_project.id, - "required_experience": WorkExperience.NO_EXPERIENCE.value, - "work_schedule": WorkSchedule.FULL_TIME.value, - "work_format": WorkFormat.REMOTE.value, - "salary": 100, - } - - def test_vacancy_creation(self): - request = self.factory.post("vacancy/", self.vacancy_create_data) - force_authenticate(request, user=self.user_project_owner) - response = self.vacancy_list_view(request) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data["role"], "Test") - self.assertEqual( - response.data["required_skills"], - [ - OrderedDict( - [ - ("id", 1), - ("name", "Ведение социальных сетей"), - ("category", OrderedDict([("id", 1), ("name", "Маркетинг")])), - ] - ), - OrderedDict( - [ - ("id", 15), - ("name", "MS Office"), - ("category", OrderedDict([("id", 1), ("name", "Маркетинг")])), - ] - ), - ], - ) - self.assertEqual(response.data["description"], "Test") - self.assertEqual(response.data["is_active"], not self.created_project.draft) - self.assertEqual(response.data["project"]["id"], self.vacancy_create_data["project"]) - self.assertEqual(response.data["required_experience"], WorkExperience.NO_EXPERIENCE.value) - self.assertEqual(response.data["work_schedule"], WorkSchedule.FULL_TIME.value) - self.assertEqual(response.data["work_format"], WorkFormat.REMOTE.value) - self.assertEqual(response.data["salary"], 100) - - def user_create(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - user.is_active = True - user.save() - return user - - -class VacancyResponseTestCase(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.user_list_view = UserList.as_view() - self.vacancy_response_list_view = VacancyResponseList.as_view() - self.vacancy_response_detail_view = VacancyResponseDetail.as_view() - - def user_create(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - user.is_active = True - user.save() - return user diff --git a/vacancy/tests/__init__.py b/vacancy/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/vacancy/tests/helpers.py b/vacancy/tests/helpers.py new file mode 100644 index 00000000..cc337c95 --- /dev/null +++ b/vacancy/tests/helpers.py @@ -0,0 +1,133 @@ +from datetime import date, timedelta +from uuid import uuid4 + +from django.utils import timezone + +from core.models import Skill, SkillCategory +from files.models import UserFile +from projects.models import Project +from users.models import CustomUser +from vacancy.constants import WorkExperience, WorkFormat, WorkSchedule +from vacancy.models import Vacancy, VacancyResponse + + +def create_user( + *, + prefix: str = "vacancy-user", + is_active: bool = True, +) -> CustomUser: + user = CustomUser.objects.create_user( + email=f"{prefix}-{uuid4().hex}@example.com", + password="very_strong_password", + first_name="Иван", + last_name="Иванов", + birthday=date(2000, 1, 1), + ) + user.is_active = is_active + user.save(update_fields=["is_active"]) + return user + + +def create_project( + *, + leader: CustomUser | None = None, + name: str = "Проект с вакансией", + draft: bool = False, + is_public: bool = True, +) -> Project: + return Project.objects.create( + leader=leader or create_user(prefix="vacancy-leader"), + name=name, + description="Описание проекта", + draft=draft, + is_public=is_public, + ) + + +def create_skill(*, name: str = "Python") -> Skill: + category, _ = SkillCategory.objects.get_or_create(name="Backend") + return Skill.objects.create(name=f"{name}-{uuid4().hex}", category=category) + + +def create_user_file( + *, + user: CustomUser, + link: str | None = None, +) -> UserFile: + return UserFile.objects.create( + user=user, + link=link or f"https://cdn.example.com/{uuid4().hex}.pdf", + name="cv", + extension="pdf", + mime_type="application/pdf", + size=1024, + ) + + +def vacancy_payload(project: Project, skills: list[Skill] | None = None, **overrides): + skills = skills if skills is not None else [create_skill()] + payload = { + "role": "Backend developer", + "specialization": "Backend", + "required_skills_ids": [skill.id for skill in skills], + "description": "Нужен Python-разработчик", + "project": project.id, + "required_experience": WorkExperience.NO_EXPERIENCE.value, + "work_schedule": WorkSchedule.FULL_TIME.value, + "work_format": WorkFormat.REMOTE.value, + "salary": 100000, + } + payload.update(overrides) + return payload + + +def create_vacancy( + *, + project: Project | None = None, + role: str = "Backend developer", + is_active: bool = True, + datetime_created=None, + required_experience: str | None = WorkExperience.NO_EXPERIENCE.name.lower(), + work_schedule: str | None = WorkSchedule.FULL_TIME.name.lower(), + work_format: str | None = WorkFormat.REMOTE.name.lower(), + salary: int | None = 100000, +) -> Vacancy: + vacancy = Vacancy.objects.create( + project=project or create_project(), + role=role, + specialization="Backend", + description="Описание вакансии", + is_active=is_active, + required_experience=required_experience, + work_schedule=work_schedule, + work_format=work_format, + salary=salary, + ) + if datetime_created is not None: + Vacancy.objects.filter(pk=vacancy.pk).update(datetime_created=datetime_created) + vacancy.refresh_from_db() + return vacancy + + +def create_old_vacancy(*, days: int = 31, **kwargs) -> Vacancy: + return create_vacancy( + datetime_created=timezone.now() - timedelta(days=days), + **kwargs, + ) + + +def create_vacancy_response( + *, + user: CustomUser | None = None, + vacancy: Vacancy | None = None, + is_approved: bool | None = None, + why_me: str = "Подхожу по опыту", + accompanying_file: UserFile | None = None, +) -> VacancyResponse: + return VacancyResponse.objects.create( + user=user or create_user(prefix="vacancy-response-user"), + vacancy=vacancy or create_vacancy(), + is_approved=is_approved, + why_me=why_me, + accompanying_file=accompanying_file, + ) diff --git a/vacancy/tests/test_models_services.py b/vacancy/tests/test_models_services.py new file mode 100644 index 00000000..bc5d2b8e --- /dev/null +++ b/vacancy/tests/test_models_services.py @@ -0,0 +1,71 @@ +from types import SimpleNamespace + +from django.test import TestCase + +from vacancy.services import update_vacancy_skills +from vacancy.tests.helpers import create_skill, create_vacancy + + +class VacancyModelTests(TestCase): + def test_datetime_closed_is_set_when_vacancy_becomes_inactive(self): + vacancy = create_vacancy(is_active=True) + + vacancy.is_active = False + vacancy.save() + + self.assertIsNotNone(vacancy.datetime_closed) + + def test_datetime_closed_is_cleared_when_vacancy_becomes_active(self): + vacancy = create_vacancy(is_active=False) + self.assertIsNotNone(vacancy.datetime_closed) + + vacancy.is_active = True + vacancy.save() + + self.assertIsNone(vacancy.datetime_closed) + + +class VacancySkillsServiceTests(TestCase): + def test_update_vacancy_skills_replaces_existing_skills(self): + old_skill = create_skill(name="Old") + new_skill = create_skill(name="New") + vacancy = create_vacancy() + vacancy.required_skills.create(skill=old_skill) + request = SimpleNamespace(data={"required_skills_ids": [new_skill.id]}) + + update_vacancy_skills(request, vacancy) + + self.assertEqual( + list(vacancy.required_skills.values_list("skill_id", flat=True)), + [new_skill.id], + ) + + def test_update_vacancy_skills_returns_error_for_missing_skill(self): + vacancy = create_vacancy() + request = SimpleNamespace(data={"required_skills_ids": [999999]}) + + response = update_vacancy_skills(request, vacancy) + + self.assertEqual(response.status_code, 400) + self.assertFalse(vacancy.required_skills.exists()) + + def test_get_required_skills_returns_skill_objects(self): + skill = create_skill(name="Python") + vacancy = create_vacancy() + vacancy.required_skills.create(skill=skill) + + self.assertEqual(vacancy.get_required_skills(), [skill]) + + def test_vacancy_string_representation_contains_role(self): + vacancy = create_vacancy(role="Backend") + + self.assertEqual(str(vacancy), f"Vacancy<{vacancy.id}> - Backend") + + def test_vacancy_response_string_representation_contains_vacancy(self): + vacancy = create_vacancy(role="Backend") + response = vacancy.vacancy_requests.create(user=vacancy.project.leader) + + self.assertEqual( + str(response), + f"VacancyResponse<{response.id}> - {response.user} - {vacancy}", + ) diff --git a/vacancy/tests/test_permissions.py b/vacancy/tests/test_permissions.py new file mode 100644 index 00000000..7aa2f6db --- /dev/null +++ b/vacancy/tests/test_permissions.py @@ -0,0 +1,64 @@ +from django.test import RequestFactory, TestCase + +from vacancy.permissions import ( + IsProjectLeaderForVacancyResponse, + IsVacancyProjectLeader, + IsVacancyResponseOwnerOrReadOnly, +) +from vacancy.tests.helpers import create_project, create_user, create_vacancy_response + + +class VacancyPermissionTests(TestCase): + def setUp(self): + self.factory = RequestFactory() + + def test_vacancy_project_leader_can_write_vacancy(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + vacancy = project.vacancies.create(role="Backend") + request = self.factory.patch("/vacancies/1/") + request.user = leader + + self.assertTrue( + IsVacancyProjectLeader().has_object_permission(request, None, vacancy) + ) + + def test_vacancy_non_leader_cannot_write_vacancy(self): + outsider = create_user(prefix="outsider") + vacancy = create_vacancy_response().vacancy + request = self.factory.patch("/vacancies/1/") + request.user = outsider + + self.assertFalse( + IsVacancyProjectLeader().has_object_permission(request, None, vacancy) + ) + + def test_response_owner_can_write_own_response(self): + vacancy_response = create_vacancy_response() + request = self.factory.patch("/vacancies/responses/1/") + request.user = vacancy_response.user + + self.assertTrue( + IsVacancyResponseOwnerOrReadOnly().has_object_permission( + request, + None, + vacancy_response, + ) + ) + + def test_project_leader_can_decide_response(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + vacancy_response = create_vacancy_response( + vacancy=project.vacancies.create(role="Backend") + ) + request = self.factory.post("/vacancies/responses/1/accept/") + request.user = leader + + self.assertTrue( + IsProjectLeaderForVacancyResponse().has_object_permission( + request, + None, + vacancy_response, + ) + ) diff --git a/vacancy/tests/test_tasks_mapping.py b/vacancy/tests/test_tasks_mapping.py new file mode 100644 index 00000000..0d68a401 --- /dev/null +++ b/vacancy/tests/test_tasks_mapping.py @@ -0,0 +1,68 @@ +from unittest.mock import patch + +from django.test import TestCase + +from vacancy.mapping import ( + MessageTypeEnum, + create_text_for_email, + get_link, +) +from vacancy.tasks import email_notificate_vacancy_outdated, send_email +from vacancy.tests.helpers import create_old_vacancy, create_project, create_user + + +class VacancyEmailMappingTests(TestCase): + def test_responded_email_points_to_project_responses(self): + data = { + "message_type": MessageTypeEnum.RESPONDED.value, + "user_id": 1, + "project_name": "Проект", + "project_id": 10, + "vacancy_role": "Backend", + "schema_id": 2, + } + + self.assertEqual( + get_link(data), + "https://app.procollab.ru/office/projects/10/responses", + ) + self.assertIn("Backend", create_text_for_email(data)) + + @patch("vacancy.tasks.send_mass_mail") + @patch("vacancy.tasks.prepare_mail_data") + def test_send_email_prepares_and_sends_mail(self, prepare_mail_data, send_mass_mail): + prepare_mail_data.return_value = {"messages": ["mail"]} + data = { + "message_type": MessageTypeEnum.ACCEPTED.value, + "user_id": 1, + "project_name": "Проект", + "project_id": 10, + "vacancy_role": "Backend", + "schema_id": 2, + } + + send_email(data) + + prepare_mail_data.assert_called_once() + send_mass_mail.assert_called_once_with(messages=["mail"]) + + +class OutdatedVacancyTaskTests(TestCase): + @patch("vacancy.tasks.send_email.delay") + def test_outdated_task_notifies_leaders_and_closes_old_active_vacancies( + self, + send_email_delay, + ): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + old_vacancy = create_old_vacancy(project=project, is_active=True, days=31) + fresh_vacancy = create_old_vacancy(project=project, is_active=True, days=10) + + email_notificate_vacancy_outdated() + + old_vacancy.refresh_from_db() + fresh_vacancy.refresh_from_db() + self.assertFalse(old_vacancy.is_active) + self.assertTrue(fresh_vacancy.is_active) + send_email_delay.assert_called_once() + self.assertEqual(send_email_delay.call_args.args[0]["user_id"], leader.id) diff --git a/vacancy/tests/test_vacancies_api.py b/vacancy/tests/test_vacancies_api.py new file mode 100644 index 00000000..012ceb8b --- /dev/null +++ b/vacancy/tests/test_vacancies_api.py @@ -0,0 +1,180 @@ +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from vacancy.models import Vacancy +from vacancy.tests.helpers import ( + create_project, + create_skill, + create_user, + create_vacancy, + create_vacancy_response, + vacancy_payload, +) + + +class VacancyAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_project_leader_can_create_vacancy(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + skill = create_skill(name="Django") + self.client.force_authenticate(leader) + + response = self.client.post( + "/vacancies/", + vacancy_payload(project, [skill], role="Django developer"), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["role"], "Django developer") + self.assertEqual(response.data["project"]["id"], project.id) + self.assertEqual(response.data["required_skills"][0]["id"], skill.id) + self.assertTrue(Vacancy.objects.get(pk=response.data["id"]).is_active) + + def test_user_cannot_create_vacancy_for_foreign_project(self): + leader = create_user(prefix="leader") + outsider = create_user(prefix="outsider") + project = create_project(leader=leader) + self.client.force_authenticate(outsider) + + response = self.client.post( + "/vacancies/", + vacancy_payload(project), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Vacancy.objects.exists()) + + def test_vacancy_for_draft_project_is_created_inactive(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader, draft=True) + self.client.force_authenticate(leader) + + response = self.client.post( + "/vacancies/", + vacancy_payload(project), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertFalse(Vacancy.objects.get(pk=response.data["id"]).is_active) + + def test_public_list_returns_only_active_vacancies_by_default(self): + active_vacancy = create_vacancy(role="Active vacancy", is_active=True) + create_vacancy(role="Inactive vacancy", is_active=False) + + response = self.client.get("/vacancies/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [active_vacancy.id], + ) + + def test_list_can_include_inactive_vacancies_by_filter(self): + create_vacancy(role="Active vacancy", is_active=True) + inactive_vacancy = create_vacancy(role="Inactive vacancy", is_active=False) + + response = self.client.get("/vacancies/", {"is_active": "false"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [inactive_vacancy.id], + ) + + def test_list_filters_by_project_role_salary_and_work_conditions(self): + project = create_project(name="Target project") + target = create_vacancy( + project=project, + role="Python backend", + salary=120000, + ) + create_vacancy(role="Frontend", salary=50000) + + response = self.client.get( + "/vacancies/", + { + "project_id": str(project.id), + "role_contains": "Python", + "salary_min": "100000", + "salary_max": "150000", + "required_experience": "no_experience", + "work_schedule": "full_time", + "work_format": "remote", + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data["results"]], [target.id]) + + def test_list_excludes_vacancies_older_than_90_days(self): + create_vacancy( + role="Old vacancy", + datetime_created=timezone.now() - timedelta(days=91), + ) + fresh = create_vacancy(role="Fresh vacancy") + + response = self.client.get("/vacancies/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data["results"]], [fresh.id]) + + def test_detail_returns_vacancy_with_project_info(self): + vacancy = create_vacancy(role="Detail vacancy") + + response = self.client.get(f"/vacancies/{vacancy.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], vacancy.id) + self.assertEqual(response.data["role"], "Detail vacancy") + self.assertEqual(response.data["project"]["id"], vacancy.project.id) + + def test_project_leader_can_close_vacancy_and_decline_pending_responses(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project, is_active=True) + response_to_decline = create_vacancy_response(vacancy=vacancy) + self.client.force_authenticate(leader) + + response = self.client.put( + f"/vacancies/{vacancy.id}/", + { + "role": vacancy.role, + "description": vacancy.description, + "is_active": False, + "required_experience": "без опыта", + "work_schedule": "полный рабочий день", + "work_format": "удаленная работа", + "salary": vacancy.salary, + }, + format="json", + ) + + response_to_decline.refresh_from_db() + vacancy.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(vacancy.is_active) + self.assertIsNotNone(vacancy.datetime_closed) + self.assertFalse(response_to_decline.is_approved) + + def test_non_leader_cannot_update_vacancy(self): + vacancy = create_vacancy() + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + response = self.client.patch( + f"/vacancies/{vacancy.id}/", + {"role": "Changed"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/vacancy/tests/test_vacancy_responses_api.py b/vacancy/tests/test_vacancy_responses_api.py new file mode 100644 index 00000000..ed28e868 --- /dev/null +++ b/vacancy/tests/test_vacancy_responses_api.py @@ -0,0 +1,200 @@ +from unittest.mock import patch + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from projects.models import Collaborator +from vacancy.models import VacancyResponse +from vacancy.tests.helpers import ( + create_project, + create_user, + create_user_file, + create_vacancy, + create_vacancy_response, +) + + +class VacancyResponseAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("vacancy.views.send_email.delay") + def test_user_can_apply_to_active_vacancy(self, send_email_delay): + user = create_user(prefix="applicant") + vacancy = create_vacancy(role="Apply vacancy", is_active=True) + file = create_user_file(user=user) + self.client.force_authenticate(user) + + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + { + "why_me": "Есть опыт", + "accompanying_file": file.link, + }, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + vacancy_response = VacancyResponse.objects.get() + self.assertEqual(vacancy_response.user, user) + self.assertEqual(vacancy_response.vacancy, vacancy) + self.assertEqual(vacancy_response.accompanying_file, file) + send_email_delay.assert_called_once() + + @patch("vacancy.views.send_email.delay") + def test_user_cannot_apply_to_closed_vacancy(self, send_email_delay): + user = create_user(prefix="applicant") + vacancy = create_vacancy(is_active=False) + self.client.force_authenticate(user) + + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + {"why_me": "Есть опыт"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(VacancyResponse.objects.exists()) + send_email_delay.assert_not_called() + + @patch("vacancy.views.send_email.delay") + def test_user_cannot_apply_twice_to_same_vacancy(self, send_email_delay): + user = create_user(prefix="applicant") + vacancy = create_vacancy() + create_vacancy_response(user=user, vacancy=vacancy) + self.client.force_authenticate(user) + + response = self.client.post( + f"/vacancies/{vacancy.id}/responses/", + {"why_me": "Повторный отклик"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(VacancyResponse.objects.count(), 1) + send_email_delay.assert_not_called() + + def test_response_list_returns_responses_for_vacancy(self): + vacancy = create_vacancy() + target_response = create_vacancy_response(vacancy=vacancy, why_me="Target") + create_vacancy_response(why_me="Other") + + response = self.client.get(f"/vacancies/{vacancy.id}/responses/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [target_response.id]) + self.assertEqual(response.data[0]["why_me"], "Target") + + def test_current_user_responses_returns_only_own_responses(self): + user = create_user(prefix="applicant") + own_response = create_vacancy_response(user=user) + create_vacancy_response() + self.client.force_authenticate(user) + + response = self.client.get("/vacancies/responses/self") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data["results"]], + [own_response.id], + ) + + def test_response_detail_returns_full_file_info_for_read(self): + user = create_user(prefix="applicant") + file = create_user_file(user=user) + vacancy_response = create_vacancy_response(user=user, accompanying_file=file) + self.client.force_authenticate(user) + + response = self.client.get(f"/vacancies/responses/{vacancy_response.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["accompanying_file"]["link"], file.link) + + +class VacancyResponseDecisionAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("vacancy.views.send_email.delay") + def test_project_leader_can_accept_response(self, send_email_delay): + leader = create_user(prefix="leader") + applicant = create_user(prefix="applicant") + project = create_project(leader=leader, draft=False) + vacancy = create_vacancy(project=project, is_active=True, role="Designer") + vacancy_response = create_vacancy_response(user=applicant, vacancy=vacancy) + self.client.force_authenticate(leader) + + response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/accept/" + ) + + vacancy_response.refresh_from_db() + vacancy.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(vacancy_response.is_approved) + self.assertFalse(vacancy.is_active) + self.assertIsNotNone(vacancy.datetime_closed) + self.assertTrue( + Collaborator.objects.filter( + project=project, + user=applicant, + role="Designer", + ).exists() + ) + self.assertEqual(send_email_delay.call_args.args[0]["user_id"], applicant.id) + + @patch("vacancy.views.send_email.delay") + def test_project_leader_can_decline_response(self, send_email_delay): + leader = create_user(prefix="leader") + applicant = create_user(prefix="applicant") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project) + vacancy_response = create_vacancy_response(user=applicant, vacancy=vacancy) + self.client.force_authenticate(leader) + + response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/decline/" + ) + + vacancy_response.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(vacancy_response.is_approved) + self.assertEqual(send_email_delay.call_args.args[0]["user_id"], applicant.id) + + @patch("vacancy.views.send_email.delay") + def test_non_leader_cannot_accept_response(self, send_email_delay): + vacancy_response = create_vacancy_response() + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/accept/" + ) + + vacancy_response.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIsNone(vacancy_response.is_approved) + send_email_delay.assert_not_called() + + @patch("vacancy.views.send_email.delay") + def test_cannot_accept_or_decline_already_processed_response(self, send_email_delay): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + vacancy = create_vacancy(project=project) + vacancy_response = create_vacancy_response( + vacancy=vacancy, + is_approved=True, + ) + self.client.force_authenticate(leader) + + accept_response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/accept/" + ) + decline_response = self.client.post( + f"/vacancies/responses/{vacancy_response.id}/decline/" + ) + + self.assertEqual(accept_response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(decline_response.status_code, status.HTTP_400_BAD_REQUEST) + send_email_delay.assert_not_called() diff --git a/vacancy/views.py b/vacancy/views.py index 8fd1edd0..2fe403f8 100644 --- a/vacancy/views.py +++ b/vacancy/views.py @@ -187,7 +187,6 @@ def post(self, request, pk): vacancy.is_active = False vacancy.save() new_collaborator.save() - vacancy.project.save() vacancy_request.save() return Response(status=status.HTTP_200_OK) @@ -210,7 +209,7 @@ def post(self, request, pk): send_email.delay( CeleryEmailParams( message_type=MessageTypeEnum.REJECTED.value, - user_id=project.leader.id, + user_id=vacancy_request.user.id, project_name=project.name, project_id=project.id, vacancy_role=vacancy_request.vacancy.role, From a90f6a6ca1f63844eda8c9e696b58372d737492d Mon Sep 17 00:00:00 2001 From: Toksi Date: Mon, 8 Jun 2026 09:28:50 +0500 Subject: [PATCH 26/32] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20Industries=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D1=8B=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/industries.md | 137 +++++++++++++++++++++- industries/permissions.py | 17 --- industries/tests.py | 103 ---------------- industries/tests/__init__.py | 0 industries/tests/helpers.py | 43 +++++++ industries/tests/test_industry_api.py | 162 ++++++++++++++++++++++++++ industries/tests/test_models.py | 10 ++ 7 files changed, 351 insertions(+), 121 deletions(-) delete mode 100644 industries/permissions.py delete mode 100644 industries/tests.py create mode 100644 industries/tests/__init__.py create mode 100644 industries/tests/helpers.py create mode 100644 industries/tests/test_industry_api.py create mode 100644 industries/tests/test_models.py diff --git a/docs/modules/industries.md b/docs/modules/industries.md index 99ed5524..62f8d14c 100644 --- a/docs/modules/industries.md +++ b/docs/modules/industries.md @@ -1,3 +1,138 @@ # Industries -TODO +## Назначение + +Industries - справочник отраслей, который используется для классификации +проектов и фильтрации проектных данных. + +Модуль не содержит сложной бизнес-логики: его основная задача - хранить +`Industry` и отдавать список отраслей клиентам и другим модулям. + +## Статус модуля + +Модуль рабочий и подключен в публичный API через `/industries/`. + +Есть небольшой долг: + +- в модели нет уникальности `name`, поэтому одинаковые отрасли можно создать + несколько раз. + +## Основные возможности + +- просмотр списка отраслей; +- просмотр одной отрасли; +- создание отрасли staff-пользователем; +- обновление отрасли staff-пользователем; +- удаление отрасли staff-пользователем; +- управление отраслями через Django admin. + +## Архитектура + +- `industries/models.py` - модель `Industry`. +- `industries/serializers.py` - `IndustrySerializer`. +- `industries/views.py` - list/create и detail/update/delete endpoints. +- `industries/urls.py` - routes модуля. +- `industries/admin.py` - регистрация модели в Django admin. +- `industries/tests/` - regression-тесты и helpers модуля. + +## Основные сущности + +### Industry + +`Industry` описывает отрасль проекта. + +Поля: + +- `id` - идентификатор отрасли; +- `name` - название отрасли, максимум 256 символов; +- `datetime_created` - дата создания. + +Сортировка по умолчанию: `name`. + +Строковое представление: + +```text +Industry - name +``` + +## API + +- `GET /industries/` - список отраслей. +- `POST /industries/` - создание отрасли. +- `GET /industries//` - детали отрасли. +- `PUT /industries//` - полное обновление отрасли. +- `PATCH /industries//` - частичное обновление отрасли. +- `DELETE /industries//` - удаление отрасли. + +Permissions: + +- read operations доступны всем пользователям; +- anonymous write operations возвращают `401`; +- authenticated non-staff write operations возвращают `403`; +- staff write operations разрешены; +- фактически используется общий permission `core.permissions.IsStaffOrReadOnly`. + +Response contract: + +```json +{ + "id": 1, + "name": "IT", + "datetime_created": "2026-01-01T00:00:00Z" +} +``` + +## Основные сценарии + +### Пользователь выбирает отрасль проекта + +Frontend получает список отраслей через `GET /industries/` и использует `id` +отрасли при создании или обновлении проекта. + +### Пользователь фильтрует проекты по отрасли + +Проекты фильтруются по `industry` через модуль `projects`. Сам справочник +только хранит отрасли и не содержит собственной логики фильтрации проектов. + +### Администратор управляет справочником + +Staff-пользователь может создавать, обновлять и удалять отрасли через API или +Django admin. + +## Связи с другими модулями + +- `projects` - `Project.industry` ссылается на `Industry`. +- `vacancy` - detail вакансии отдает проект вместе с отраслью проекта. +- `partner_programs` - serializers программных проектов отдают отрасль проекта. +- `project_rates` - serializers оценок проектов отдают отрасль проекта. +- `news`, `projects` и другие тестовые helpers создают отрасли для связанных + сценариев. + +## Ограничения и риски + +- `Industry.name` не уникален. Сейчас можно создать несколько отраслей с + одинаковым названием. +- При удалении отрасли у связанных проектов `Project.industry` становится + `NULL`, потому что связь настроена через `on_delete=SET_NULL`. +- Удаление или переименование отрасли может повлиять на фильтрацию и отображение + проектов на frontend. + +## Тесты + +Текущие тесты лежат в `industries/tests/`. + +Проверяется: + +- публичный список отраслей через реальный URL; +- публичный detail отрасли через реальный URL; +- 404 для отсутствующей отрасли; +- создание отрасли staff-пользователем; +- запрет создания отрасли anonymous-пользователем; +- запрет создания и обновления отрасли обычным пользователем; +- обновление отрасли staff-пользователем; +- удаление отрасли staff-пользователем; +- отвязка проектов от удаленной отрасли через `on_delete=SET_NULL`; +- ошибка при отсутствующем `name`; +- ошибка при пустом `name`; +- ошибка при слишком длинном `name`; +- строковое представление `Industry`. diff --git a/industries/permissions.py b/industries/permissions.py deleted file mode 100644 index 230c5bee..00000000 --- a/industries/permissions.py +++ /dev/null @@ -1,17 +0,0 @@ -from rest_framework.permissions import BasePermission, SAFE_METHODS - - -class IndustryPermission(BasePermission): - """ - Allows access to update only to staff users. - """ - - def has_permission(self, request, view) -> bool: - if request.method in SAFE_METHODS or request.user and request.user.is_staff: - return True - return False - - def has_object_permission(self, request, view, obj) -> bool: - if request.method in SAFE_METHODS or request.user and request.user.is_staff: - return True - return False diff --git a/industries/tests.py b/industries/tests.py deleted file mode 100644 index 13ee3b2c..00000000 --- a/industries/tests.py +++ /dev/null @@ -1,103 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIRequestFactory, force_authenticate -from tests.constants import USER_CREATE_DATA -from users.models import CustomUser -from users.views import UserList - -from industries.models import Industry -from industries.views import IndustryDetail, IndustryList - - -class IndustryTestCase(TestCase): - """Tests for industries+""" - - def setUp(self): - self.factory = APIRequestFactory() - - self.user_list_view = UserList.as_view() - - self.industry_list_view = IndustryList.as_view() - self.industry_detail_view = IndustryDetail.as_view() - - self.INDUSTRY_NAME = "Test Industry" - self.CREATE_DATA = { - "name": self.INDUSTRY_NAME, - } - - def test_industry_creation(self): - user = self._user_create() - request = self.factory.post("industries/", self.CREATE_DATA) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data["name"], self.INDUSTRY_NAME) - - def test_industry_creation_with_too_long_name(self): - user = self._user_create() - request = self.factory.post("industries/", {"name": "too_long_string_" * 257}) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_industry_creation_with_empty_name(self): - user = self._user_create() - request = self.factory.post("industries/", {"name": ""}) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_industry_creation_with_wrong_data(self): - user = self._user_create() - request = self.factory.post("industries/", {"wrong_name": "Wrong value"}) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_industry_creation_with_empty_data(self): - user = self._user_create() - request = self.factory.post("industries/", {}) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_industry_update(self): - user = self._user_create() - request = self.factory.post("industries/", self.CREATE_DATA) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - - industry_id = response.data["id"] - industry = Industry.objects.get(id=industry_id) - - request = self.factory.patch(f"industries/{industry.pk}/", {"name": "Test2"}) - force_authenticate(request, user=user) - response = self.industry_detail_view(request, pk=industry.pk) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["name"], "Test2") - - def test_industry_update_with_wrong_data(self): - user = self._user_create() - request = self.factory.post("industries/", self.CREATE_DATA) - force_authenticate(request, user=user) - response = self.industry_list_view(request) - - industry_id = response.data["id"] - industry = Industry.objects.get(id=industry_id) - - request = self.factory.patch(f"industries/{industry.pk}/", {"name": ""}) - force_authenticate(request, user=user) - response = self.industry_detail_view(request, pk=industry.pk) - - self.assertEqual(response.status_code, 400) - - def _user_create(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - user.is_active = True - user.is_staff = True - user.save() - return user diff --git a/industries/tests/__init__.py b/industries/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/industries/tests/helpers.py b/industries/tests/helpers.py new file mode 100644 index 00000000..edb31c80 --- /dev/null +++ b/industries/tests/helpers.py @@ -0,0 +1,43 @@ +from datetime import date +from uuid import uuid4 + +from industries.models import Industry +from projects.models import Project +from users.models import CustomUser + + +def unique_suffix() -> str: + return uuid4().hex[:8] + + +def create_user(*, prefix: str = "industry-user", is_staff: bool = False): + user = CustomUser.objects.create_user( + email=f"{prefix}-{unique_suffix()}@example.com", + password="very_strong_password", + first_name="Иван", + last_name="Иванов", + birthday=date(2000, 1, 1), + ) + user.is_active = True + user.is_staff = is_staff + user.save(update_fields=["is_active", "is_staff"]) + return user + + +def create_industry(*, name: str = "Industry") -> Industry: + return Industry.objects.create(name=f"{name} {unique_suffix()}") + + +def create_project_with_industry( + *, + industry: Industry, + leader: CustomUser | None = None, +) -> Project: + return Project.objects.create( + leader=leader or create_user(prefix="project-leader"), + name=f"Project {unique_suffix()}", + description="Проект с отраслью", + industry=industry, + draft=False, + is_public=True, + ) diff --git a/industries/tests/test_industry_api.py b/industries/tests/test_industry_api.py new file mode 100644 index 00000000..32ea66b1 --- /dev/null +++ b/industries/tests/test_industry_api.py @@ -0,0 +1,162 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from industries.models import Industry +from industries.tests.helpers import ( + create_industry, + create_project_with_industry, + create_user, +) + + +class IndustryReadAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_anonymous_user_can_get_industry_list_ordered_by_name(self): + beta = create_industry(name="Beta") + alpha = create_industry(name="Alpha") + + response = self.client.get("/industries/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in response.data], + [alpha.id, beta.id], + ) + + def test_anonymous_user_can_get_industry_detail(self): + industry = create_industry(name="Robotics") + + response = self.client.get(f"/industries/{industry.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], industry.id) + self.assertEqual(response.data["name"], industry.name) + self.assertIn("datetime_created", response.data) + + def test_missing_industry_returns_404(self): + response = self.client.get("/industries/999999/") + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class IndustryWriteAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_staff_user_can_create_industry(self): + staff = create_user(prefix="staff", is_staff=True) + self.client.force_authenticate(staff) + + response = self.client.post( + "/industries/", + {"name": "Новая отрасль"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["name"], "Новая отрасль") + self.assertTrue(Industry.objects.filter(name="Новая отрасль").exists()) + + def test_anonymous_user_must_authenticate_to_create_industry(self): + response = self.client.post( + "/industries/", + {"name": "Закрытая отрасль"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertFalse(Industry.objects.filter(name="Закрытая отрасль").exists()) + + def test_regular_user_cannot_create_industry(self): + user = create_user(prefix="regular") + self.client.force_authenticate(user) + + response = self.client.post( + "/industries/", + {"name": "Закрытая отрасль"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(Industry.objects.filter(name="Закрытая отрасль").exists()) + + def test_staff_user_can_update_industry(self): + staff = create_user(prefix="staff", is_staff=True) + industry = create_industry(name="Old") + self.client.force_authenticate(staff) + + response = self.client.patch( + f"/industries/{industry.id}/", + {"name": "Updated industry"}, + format="json", + ) + + industry.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(industry.name, "Updated industry") + + def test_regular_user_cannot_update_industry(self): + user = create_user(prefix="regular") + industry = create_industry(name="Protected") + self.client.force_authenticate(user) + + response = self.client.patch( + f"/industries/{industry.id}/", + {"name": "Unexpected"}, + format="json", + ) + + industry.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertNotEqual(industry.name, "Unexpected") + + def test_staff_user_can_delete_industry_and_detach_projects(self): + staff = create_user(prefix="staff", is_staff=True) + industry = create_industry(name="Temporary") + project = create_project_with_industry(industry=industry) + self.client.force_authenticate(staff) + + response = self.client.delete(f"/industries/{industry.id}/") + + project.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Industry.objects.filter(pk=industry.pk).exists()) + self.assertIsNone(project.industry) + + def test_name_is_required_on_create(self): + staff = create_user(prefix="staff", is_staff=True) + self.client.force_authenticate(staff) + + response = self.client.post("/industries/", {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("name", response.data) + + def test_name_cannot_be_blank(self): + staff = create_user(prefix="staff", is_staff=True) + self.client.force_authenticate(staff) + + response = self.client.post( + "/industries/", + {"name": ""}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("name", response.data) + + def test_name_cannot_exceed_256_symbols(self): + staff = create_user(prefix="staff", is_staff=True) + self.client.force_authenticate(staff) + + response = self.client.post( + "/industries/", + {"name": "x" * 257}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("name", response.data) diff --git a/industries/tests/test_models.py b/industries/tests/test_models.py new file mode 100644 index 00000000..f07070d1 --- /dev/null +++ b/industries/tests/test_models.py @@ -0,0 +1,10 @@ +from django.test import TestCase + +from industries.tests.helpers import create_industry + + +class IndustryModelTests(TestCase): + def test_string_representation_contains_id_and_name(self): + industry = create_industry(name="Fintech") + + self.assertEqual(str(industry), f"Industry<{industry.id}> - {industry.name}") From fa1c7b6f38616a9ad27a5a7a5e5c4640e2b5ce66 Mon Sep 17 00:00:00 2001 From: Toksi Date: Mon, 8 Jun 2026 14:09:34 +0500 Subject: [PATCH 27/32] =?UTF-8?q?=D0=9D=D0=B0=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F,=20=D1=80=D0=B0=D1=81=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=20Invites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/invites.md | 218 +++++++++++++++- invites/filters.py | 33 ++- invites/permissions.py | 46 ++++ invites/querysets.py | 15 ++ invites/tests.py | 254 ------------------- invites/tests/__init__.py | 0 invites/tests/helpers.py | 133 ++++++++++ invites/tests/test_invite_api.py | 277 +++++++++++++++++++++ invites/tests/test_invite_decisions.py | 126 ++++++++++ invites/tests/test_invite_detail_access.py | 142 +++++++++++ invites/tests/test_models.py | 16 ++ invites/views.py | 40 +-- 12 files changed, 1015 insertions(+), 285 deletions(-) create mode 100644 invites/permissions.py create mode 100644 invites/querysets.py delete mode 100644 invites/tests.py create mode 100644 invites/tests/__init__.py create mode 100644 invites/tests/helpers.py create mode 100644 invites/tests/test_invite_api.py create mode 100644 invites/tests/test_invite_decisions.py create mode 100644 invites/tests/test_invite_detail_access.py create mode 100644 invites/tests/test_models.py diff --git a/docs/modules/invites.md b/docs/modules/invites.md index dcca3981..48efddfa 100644 --- a/docs/modules/invites.md +++ b/docs/modules/invites.md @@ -1,3 +1,219 @@ # Invites -TODO +## Назначение + +Invites отвечает за приглашения пользователей в команду проекта. + +Модуль закрывает сценарий, когда лидер проекта приглашает пользователя на роль в +проекте, а пользователь принимает или отклоняет приглашение. + +## Статус модуля + +Модуль рабочий и подключен в публичный API через `/invites/`. + +Приглашения доступны приглашенному пользователю, лидеру проекта и +staff/superuser. Изменять или удалять приглашение может лидер проекта. Принять +или отклонить приглашение может приглашенный пользователь. + +## Основные возможности + +- создание приглашения лидером проекта; +- просмотр списка активных приглашений; +- фильтрация приглашений по проекту и пользователю; +- просмотр, обновление и удаление приглашения; +- принятие приглашения пользователем; +- отклонение приглашения пользователем; +- автоматическое добавление пользователя в collaborators проекта после принятия; +- запрет приглашения лидера проекта; +- запрет приглашения пользователя, который уже состоит в проекте; +- запрет повторного активного приглашения в тот же проект; +- проверка участия пользователя в партнерской программе, если проект привязан к + программе. + +## Архитектура + +- `invites/models.py` - модель `Invite`. +- `invites/serializers.py` - serializers создания и чтения приглашений. +- `invites/views.py` - API endpoints и основная orchestration logic. +- `invites/filters.py` - фильтры списка приглашений. +- `invites/managers.py` - queryset helper для списка. +- `invites/querysets.py` - queryset видимых приглашений для текущего + пользователя. +- `invites/permissions.py` - object-level permissions для detail и + accept/decline. +- `invites/urls.py` - routes модуля. +- `invites/admin.py` - регистрация приглашений в Django admin. +- `invites/tests/` - regression-тесты и helpers модуля. + +## Основные сущности + +### Invite + +`Invite` хранит приглашение пользователя в проект. + +Поля: + +- `project` - проект, в который приглашают пользователя; +- `user` - приглашенный пользователь; +- `motivational_letter` - текст приглашения; +- `role` - роль пользователя в проекте после принятия; +- `specialization` - специализация пользователя после принятия; +- `is_accepted` - статус приглашения: + - `None` - ожидает решения; + - `True` - принято; + - `False` - отклонено; +- `datetime_created` - дата создания; +- `datetime_updated` - дата обновления. + +Сортировка по умолчанию: новые приглашения выше. + +## API + +- `GET /invites/` - список активных приглашений. +- `POST /invites/` - создание приглашения. +- `GET /invites//` - детали приглашения. +- `PUT /invites//` - полное обновление приглашения. +- `PATCH /invites//` - частичное обновление приглашения. +- `DELETE /invites//` - удаление приглашения. +- `POST /invites//accept/` - принять приглашение. +- `POST /invites//decline/` - отклонить приглашение. + +Фильтры списка: + +- `project` - фильтр по проекту; +- `user` - фильтр по пользователю; +- `user=any` - staff-only фильтр для отключения пользовательского фильтра. + +Список всегда ограничен активными приглашениями: `is_accepted IS NULL`. + +## Доступ и права + +- список приглашений доступен только authenticated пользователю; +- приглашенный пользователь видит свои приглашения; +- лидер проекта видит приглашения своего проекта через `project=`; +- staff/superuser может получить все активные приглашения через `user=any`; +- `user=` работает только внутри уже разрешенной области видимости; +- `user=any` запрещен для обычных пользователей и лидеров проекта; +- detail приглашения видят только приглашенный пользователь, лидер проекта и + staff/superuser; +- обновить или удалить приглашение может только лидер проекта; +- принять или отклонить приглашение может только приглашенный пользователь; +- повторный `accept` или `decline` по уже обработанному приглашению возвращает + `409 Conflict` и не меняет финальный статус. + +## Основные сценарии + +### 1. Лидер проекта создает приглашение + +Лидер проекта отправляет `POST /invites/` с проектом, пользователем, ролью, +специализацией и текстом приглашения. + +При создании: + +- serializer проверяет, что пользователь не является лидером проекта; +- serializer проверяет, что пользователь еще не collaborator проекта; +- serializer запрещает повторное активное приглашение в тот же проект; +- если проект привязан к партнерской программе, пользователь должен быть + участником этой программы; +- view отдельно проверяет, что текущий пользователь является лидером проекта. + +### 2. Пользователь видит свои приглашения + +Пользователь получает список через `GET /invites/`. + +По умолчанию фильтр `user` подставляется из `request.user`, поэтому +приглашенный пользователь видит свои активные приглашения. Лидер проекта +получает активные приглашения проекта через `GET /invites/?project=`. Staff +или superuser может работать со всем списком через `GET /invites/?user=any`. + +### 3. Пользователь принимает приглашение + +Пользователь вызывает `POST /invites//accept/`. + +При успешном принятии: + +- проверяется, что приглашение принимает именно приглашенный пользователь; +- если приглашение уже принято или отклонено, возвращается conflict; +- пользователь добавляется в `Collaborator` проекта; +- в collaborator переносятся `role` и `specialization` из приглашения; +- приглашение получает `is_accepted=True`. + +### 4. Пользователь отклоняет приглашение + +Пользователь вызывает `POST /invites//decline/`. + +При успешном отклонении: + +- проверяется, что приглашение отклоняет именно приглашенный пользователь; +- если приглашение уже принято или отклонено, возвращается conflict; +- приглашение получает `is_accepted=False`. + +### 5. Приглашение влияет на доступ к проекту + +В `projects.permissions` приглашенный пользователь считается вовлеченным в +проект. Это дает доступ к непубличному или draft-проекту до принятия +приглашения. + +## Связи с другими модулями + +- `projects` - приглашение всегда связано с проектом; после принятия создается + `Collaborator`. +- `users` - приглашение связано с приглашенным пользователем и лидером проекта. +- `partner_programs` - если проект связан с партнерской программой, приглашать + можно только участника этой программы. +- `projects.permissions` - invite участвует в проверках доступа к непубличным и + draft-проектам. + +## Ограничения и риски + +- Проверка участия в партнерской программе смотрит только первую связь + `project.program_links.first()`. Если проект может быть связан с несколькими + программами, этот контракт нужно уточнить. +- Нет DB-level constraint для запрета нескольких активных приглашений одного + пользователя в один проект. Сейчас это защищено только serializer validation. +- Вся orchestration logic находится во views/serializers, отдельного service + layer пока нет. + +## Тесты + +Текущие тесты лежат в `invites/tests/`. + +Проверяется: + +- создание приглашения лидером проекта; +- создание приглашения без motivational letter; +- запрет создания приглашения пользователем, который не является лидером + проекта; +- ошибка при пустом payload; +- запрет приглашения лидера проекта; +- запрет приглашения существующего collaborator; +- запрет повторного активного приглашения в тот же проект; +- запрет приглашения пользователя, который не является участником программы + проекта; +- создание приглашения для участника программы, если проект привязан к + программе; +- список активных приглашений текущего пользователя; +- фильтр списка по проекту: лидер проекта видит все активные приглашения этого + проекта; +- фильтр списка по пользователю внутри разрешенной области видимости; +- `user=any` доступен только staff/superuser; +- запрет anonymous list; +- права чтения detail: приглашенный пользователь, лидер проекта и staff имеют + доступ, outsider и anonymous не имеют; +- права update/delete: только лидер проекта может менять или удалять + приглашение; +- принятие приглашения приглашенным пользователем с созданием collaborator и + переносом `role` / `specialization`; +- отклонение приглашения приглашенным пользователем; +- запрет accept/decline чужим пользователем; +- запрет accept/decline лидером проекта вместо приглашенного пользователя; +- conflict при повторном accept уже обработанного приглашения; +- conflict при повторном decline уже обработанного приглашения; +- защита decline после accept: статус остается принятым, collaborator остается + в проекте; +- строковое представление `Invite`. + +Сейчас не покрыты: + +- DB-level constraint для повторных активных приглашений; +- сценарий проекта, связанного с несколькими партнерскими программами. diff --git a/invites/filters.py b/invites/filters.py index 635a3cf2..9c62215d 100644 --- a/invites/filters.py +++ b/invites/filters.py @@ -1,9 +1,16 @@ from django_filters import rest_framework as filters +from rest_framework.exceptions import PermissionDenied from invites.models import Invite from vacancy.filters import project_id_filter +def _first_filter_value(value): + if isinstance(value, list): + return value[0] if value else None + return value + + class InviteFilter(filters.FilterSet): """Filter for Invite @@ -12,13 +19,15 @@ class InviteFilter(filters.FilterSet): Parameters to filter by: project (int), user (default to request.user if not set otherwise) (int), - user=any (disable user filter) + user=any (available only for staff; disables user filter inside already + visible queryset) Examples: ?project=1 equals to .filter(project_id=1) (no params passed) equals to .filter(user=request.user) ?user=4 equals to .filter(user_id=4) - ?project=1&user=any equals to .filter(project_id=1) + ?project=1 for project leader equals to all active project invites + ?user=any for staff equals to all active invites """ def __init__(self, *args, **kwargs): @@ -26,17 +35,19 @@ def __init__(self, *args, **kwargs): self.data = dict(self.data) request = kwargs.get("request") if request and request.user.is_authenticated: - user_value = self.data.get("user") - if isinstance(user_value, list): - user_value = user_value[0] if user_value else None - if user_value is None: + user_value = _first_filter_value(self.data.get("user")) + project_value = _first_filter_value(self.data.get("project")) + if user_value is None and project_value in (None, ""): self.data["user"] = request.user.id - @staticmethod - def filter_user(queryset, name, value): - if isinstance(value, list): - value = value[0] if value else None - if value in (None, "", "any"): + def filter_user(self, queryset, name, value): + value = _first_filter_value(value) + if value == "any": + user = getattr(getattr(self, "request", None), "user", None) + if not user or not (user.is_staff or user.is_superuser): + raise PermissionDenied("Фильтр user=any доступен только staff.") + return queryset + if value in (None, ""): return queryset return queryset.filter(user_id=value) diff --git a/invites/permissions.py b/invites/permissions.py new file mode 100644 index 00000000..804028ca --- /dev/null +++ b/invites/permissions.py @@ -0,0 +1,46 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +def can_view_invite(user, invite) -> bool: + if not user or not user.is_authenticated: + return False + + return ( + user.is_staff + or user.is_superuser + or invite.user_id == user.id + or invite.project.leader_id == user.id + ) + + +def can_manage_invite(user, invite) -> bool: + if not user or not user.is_authenticated: + return False + + return invite.project.leader_id == user.id + + +def can_decide_invite(user, invite) -> bool: + if not user or not user.is_authenticated: + return False + + return invite.user_id == user.id + + +class InviteDetailPermission(BasePermission): + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated) + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return can_view_invite(request.user, obj) + + return can_manage_invite(request.user, obj) + + +class InviteDecisionPermission(BasePermission): + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated) + + def has_object_permission(self, request, view, obj): + return can_decide_invite(request.user, obj) diff --git a/invites/querysets.py b/invites/querysets.py new file mode 100644 index 00000000..55c2bc4c --- /dev/null +++ b/invites/querysets.py @@ -0,0 +1,15 @@ +from django.db.models import Q, QuerySet + +from invites.models import Invite + + +def get_visible_invites_queryset(user) -> QuerySet[Invite]: + queryset = Invite.objects.get_invite_for_list_view() + + if not user or not user.is_authenticated: + return queryset.none() + + if user.is_staff or user.is_superuser: + return queryset + + return queryset.filter(Q(user_id=user.id) | Q(project__leader_id=user.id)) diff --git a/invites/tests.py b/invites/tests.py deleted file mode 100644 index 5f4b3d21..00000000 --- a/invites/tests.py +++ /dev/null @@ -1,254 +0,0 @@ -from datetime import timedelta - -from django.test import TestCase -from django.utils import timezone -from rest_framework.test import APIRequestFactory, force_authenticate - -from partner_programs.models import PartnerProgram, PartnerProgramProject -from projects.models import Collaborator, Project -from tests.constants import USER_CREATE_DATA - -from users.views import UserList -from users.models import CustomUser -from invites.views import InviteList, InviteDetail, InviteAccept, InviteDecline -from invites.models import Invite -from industries.models import Industry -from projects.views import ProjectList, ProjectDetail - - -class InvitesTestCase(TestCase): - def setUp(self) -> None: - self.factory = APIRequestFactory() - - self.user_list_view = UserList.as_view() - - self.invite_list_view = InviteList.as_view() - self.invite_detail_view = InviteDetail.as_view() - - self.project_list_view = ProjectList.as_view() - self.project_detail_view = ProjectDetail.as_view() - - self.invite_create_data = { - "project": "Test", - "user": None, - "motivational_letter": "hello", - "role": "Developer", - } - - self.project_create_data = { - "name": "Test", - "description": "Test", - "industry": Industry.objects.create(name="Test").id, - "draft": False, - } - - def test_invites_creation(self): - user_main = self._user_create("example@gmail.com") - user2 = self._user_create("example2@gmail.com") - project = self._project_create(user_main) - - create_user = self.invite_create_data.copy() - create_user["user"] = user2.id - create_user["project"] = project.id - request = self.factory.post("invites/", create_user, format="json") - force_authenticate(request, user=user_main) - - response = self.invite_list_view(request) - - self.assertEqual(response.status_code, 201) - self.assertEqual( - response.data["motivational_letter"], - create_user["motivational_letter"], - ) - self.assertEqual(response.data["project"]["id"], create_user["project"]) - self.assertEqual(response.data["role"], create_user["role"]) - self.assertIsNone(response.data["is_accepted"]) - - def test_invites_creation_with_empty_text(self): - user_main = self._user_create("example@gmail.com") - user2 = self._user_create("example2@gmail.com") - project = self._project_create(user_main) - - empty_text = self.invite_create_data.copy() - empty_text["user"] = user2.id - empty_text["project"] = project.id - empty_text["motivational_letter"] = None - request = self.factory.post("invites/", empty_text, format="json") - force_authenticate(request, user=user_main) - - response = self.invite_list_view(request) - - self.assertEqual(response.status_code, 201) - - def test_invites_update(self): - user_main = self._user_create("example@gmail.com") - user2 = self._user_create("example2@gmail.com") - project = self._project_create(user_main) - - updater = self.invite_create_data.copy() - updater["user"] = user2.id - updater["project"] = project.id - request = self.factory.post("invites/", updater, format="json") - force_authenticate(request, user=user_main) - - response = self.invite_list_view(request) - - invite_id = response.data["id"] - invite = Invite.objects.get(id=invite_id) - - request = self.factory.patch( - f"invites/{invite.pk}/", {"motivational_letter": "HELLO GUYS!"} - ) - force_authenticate(request, user=user_main) - response = self.invite_detail_view(request, pk=invite.pk) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["motivational_letter"], "HELLO GUYS!") - - def test_invites_creation_with_empty_data(self): - user_main = self._user_create("example@gmail.com") - - empty_data = {} - - request = self.factory.post("invites/", empty_data, format="json") - force_authenticate(request, user=user_main) - response = self.invite_list_view(request) - - self.assertEqual(response.status_code, 400) - - def test_invites_creation_for_existing_collaborator(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - project = self._project_create(sender) - Collaborator.objects.create(user=recipient, project=project, role="Developer") - - create_user = self.invite_create_data.copy() - create_user["user"] = recipient.id - create_user["project"] = project.id - request = self.factory.post("invites/", create_user, format="json") - force_authenticate(request, user=sender) - - response = self.invite_list_view(request) - - self.assertEqual(response.status_code, 400) - self.assertIn("user", response.data) - - def test_invites_creation_for_non_program_member(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - project = self._project_create(sender) - now = timezone.now() - program = PartnerProgram.objects.create( - name="Test program", - tag="test", - city="Moscow", - datetime_registration_ends=now + timedelta(days=1), - datetime_started=now, - datetime_finished=now + timedelta(days=30), - ) - PartnerProgramProject.objects.create( - partner_program=program, project=project - ) - - create_user = self.invite_create_data.copy() - create_user["user"] = recipient.id - create_user["project"] = project.id - request = self.factory.post("invites/", create_user, format="json") - force_authenticate(request, user=sender) - - response = self.invite_list_view(request) - - self.assertEqual(response.status_code, 400) - self.assertIn("user", response.data) - - def test_accept_invite_by_intended_user(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - project = self._project_create(sender) - - invite = self._create_invite(sender, recipient, project) - - request = self.factory.post(f"/invites/{invite.id}/accept/") - force_authenticate(request, user=recipient) - accept_response = InviteAccept.as_view()(request, pk=invite.id) - - self.assertEqual(accept_response.status_code, 200) - invite.refresh_from_db() - self.assertTrue(invite.is_accepted) - - def test_decline_invite_by_intended_user(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - project = self._project_create(sender) - - invite = self._create_invite(sender, recipient, project) - - request = self.factory.post(f"/invites/{invite.id}/decline/") - force_authenticate(request, user=recipient) - decline_response = InviteDecline.as_view()(request, pk=invite.id) - - self.assertEqual(decline_response.status_code, 200) - invite.refresh_from_db() - self.assertFalse(invite.is_accepted) - - def test_accept_decline_invite_by_unintended_user(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - unintended_user = self._user_create("unintended@example.com") - project = self._project_create(sender) - - invite = self._create_invite(sender, recipient, project) - - accept_request = self.factory.post(f"/invites/{invite.id}/accept/") - force_authenticate(accept_request, user=unintended_user) - accept_response = InviteAccept.as_view()(accept_request, pk=invite.id) - - decline_request = self.factory.post(f"/invites/{invite.id}/decline/") - force_authenticate(decline_request, user=unintended_user) - decline_response = InviteDecline.as_view()(decline_request, pk=invite.id) - - self.assertNotEqual(accept_response.status_code, 200) - self.assertNotEqual(decline_response.status_code, 200) - - def test_delete_invite_by_sender(self): - sender = self._user_create("sender@example.com") - recipient = self._user_create("recipient@example.com") - project = self._project_create(sender) - - invite = self._create_invite(sender, recipient, project) - - request = self.factory.delete(f"/invites/{invite.id}/") - force_authenticate(request, user=sender) - delete_response = self.invite_detail_view(request, pk=invite.id) - - self.assertEqual(delete_response.status_code, 204) - self.assertFalse(Invite.objects.filter(pk=invite.id).exists()) - - def _project_create(self, user): - request = self.factory.post("projects/", self.project_create_data) - force_authenticate(request, user=user) - - response = self.project_list_view(request) - project_id = response.data["id"] - return Project.objects.get(pk=project_id) - - def _user_create(self, email): - tmp_create_data = USER_CREATE_DATA.copy() - tmp_create_data["email"] = email - request = self.factory.post("auth/users/", tmp_create_data) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - user.is_active = True - user.save() - return user - - def _create_invite(self, sender, recipient, project): - invite_data = self.invite_create_data.copy() - invite_data.update({"project": project.id, "user": recipient.id}) - - request = self.factory.post("/invites/", invite_data, format="json") - force_authenticate(request, user=sender) - response = self.invite_list_view(request) - - return Invite.objects.get(pk=response.data["id"]) diff --git a/invites/tests/__init__.py b/invites/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/invites/tests/helpers.py b/invites/tests/helpers.py new file mode 100644 index 00000000..0814bc17 --- /dev/null +++ b/invites/tests/helpers.py @@ -0,0 +1,133 @@ +from datetime import date, timedelta +from uuid import uuid4 + +from django.utils import timezone + +from industries.models import Industry +from invites.models import Invite +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from projects.models import Collaborator, Project +from users.models import CustomUser + + +def unique_suffix() -> str: + return uuid4().hex[:8] + + +def create_user(*, prefix: str = "invite-user", is_staff: bool = False) -> CustomUser: + user = CustomUser.objects.create_user( + email=f"{prefix}-{unique_suffix()}@example.com", + password="very_strong_password", + first_name="Иван", + last_name="Иванов", + birthday=date(2000, 1, 1), + ) + user.is_active = True + user.is_staff = is_staff + user.save(update_fields=["is_active", "is_staff"]) + return user + + +def create_industry(*, name: str = "Industry") -> Industry: + return Industry.objects.create(name=f"{name} {unique_suffix()}") + + +def create_project( + *, + leader: CustomUser | None = None, + name: str = "Invite project", + draft: bool = False, + is_public: bool = True, +) -> Project: + return Project.objects.create( + leader=leader or create_user(prefix="project-leader"), + name=f"{name} {unique_suffix()}", + description="Проект для приглашений", + industry=create_industry(), + draft=draft, + is_public=is_public, + ) + + +def invite_payload(project: Project, user: CustomUser, **overrides) -> dict: + payload = { + "project": project.id, + "user": user.id, + "motivational_letter": "Хотим пригласить вас в команду", + "role": "Developer", + "specialization": "Backend", + } + payload.update(overrides) + return payload + + +def create_invite( + *, + project: Project | None = None, + user: CustomUser | None = None, + is_accepted: bool | None = None, + role: str | None = "Developer", + specialization: str | None = "Backend", + motivational_letter: str | None = "Хотим пригласить вас в команду", +) -> Invite: + return Invite.objects.create( + project=project or create_project(), + user=user or create_user(prefix="recipient"), + is_accepted=is_accepted, + role=role, + specialization=specialization, + motivational_letter=motivational_letter, + ) + + +def add_collaborator( + *, + project: Project, + user: CustomUser, + role: str = "Developer", + specialization: str | None = "Backend", +) -> Collaborator: + return Collaborator.objects.create( + project=project, + user=user, + role=role, + specialization=specialization, + ) + + +def create_program() -> PartnerProgram: + now = timezone.now() + return PartnerProgram.objects.create( + name=f"Program {unique_suffix()}", + tag=f"program-{unique_suffix()}", + city="Moscow", + datetime_registration_ends=now + timedelta(days=1), + datetime_started=now, + datetime_finished=now + timedelta(days=30), + ) + + +def link_project_to_program( + *, + project: Project, + program: PartnerProgram | None = None, +) -> PartnerProgram: + program = program or create_program() + PartnerProgramProject.objects.create(partner_program=program, project=project) + return program + + +def add_user_to_program( + *, + user: CustomUser, + program: PartnerProgram, +) -> PartnerProgramUserProfile: + return PartnerProgramUserProfile.objects.create( + user=user, + partner_program=program, + partner_program_data={}, + ) diff --git a/invites/tests/test_invite_api.py b/invites/tests/test_invite_api.py new file mode 100644 index 00000000..faf8508f --- /dev/null +++ b/invites/tests/test_invite_api.py @@ -0,0 +1,277 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from invites.models import Invite +from invites.tests.helpers import ( + add_collaborator, + add_user_to_program, + create_invite, + create_project, + create_user, + invite_payload, + link_project_to_program, +) + + +class InviteCreateAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_project_leader_can_create_invite(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient, role="Designer"), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["project"]["id"], project.id) + self.assertEqual(response.data["user"]["id"], recipient.id) + self.assertEqual(response.data["sender"]["id"], leader.id) + self.assertEqual(response.data["role"], "Designer") + self.assertIsNone(response.data["is_accepted"]) + + def test_project_leader_can_create_invite_without_motivational_letter(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient, motivational_letter=None), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIsNone(response.data["motivational_letter"]) + + def test_non_leader_cannot_create_invite(self): + leader = create_user(prefix="leader") + outsider = create_user(prefix="outsider") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + self.client.force_authenticate(outsider) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(Invite.objects.exists()) + + def test_create_invite_requires_payload(self): + leader = create_user(prefix="leader") + self.client.force_authenticate(leader) + + response = self.client.post("/invites/", {}, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Invite.objects.exists()) + + def test_cannot_invite_project_leader(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, leader), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("user", response.data) + + def test_cannot_invite_existing_collaborator(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + add_collaborator(project=project, user=recipient) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("user", response.data) + + def test_cannot_create_duplicate_active_invite(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + create_invite(project=project, user=recipient) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(Invite.objects.filter(project=project, user=recipient).count(), 1) + + def test_program_project_invite_requires_program_membership(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + link_project_to_program(project=project) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("user", response.data) + + def test_program_member_can_be_invited_to_program_project(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + program = link_project_to_program(project=project) + add_user_to_program(user=recipient, program=program) + self.client.force_authenticate(leader) + + response = self.client.post( + "/invites/", + invite_payload(project, recipient), + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["user"]["id"], recipient.id) + + +class InviteListFilterAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_list_returns_current_user_active_invites_by_default(self): + user = create_user(prefix="recipient") + own_invite = create_invite(user=user) + create_invite() + create_invite(user=user, is_accepted=True) + create_invite(user=user, is_accepted=False) + self.client.force_authenticate(user) + + response = self.client.get("/invites/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [own_invite.id]) + + def test_list_can_filter_by_project(self): + user = create_user(prefix="recipient") + project = create_project() + target_invite = create_invite(project=project, user=user) + create_invite(user=user) + self.client.force_authenticate(user) + + response = self.client.get("/invites/", {"project": project.id}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [target_invite.id]) + + def test_user_filter_does_not_expose_foreign_invites(self): + user = create_user(prefix="recipient") + other_user = create_user(prefix="other") + create_invite(user=other_user) + self.client.force_authenticate(user) + + response = self.client.get("/invites/", {"user": other_user.id}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, []) + + def test_user_any_filter_is_forbidden_for_regular_user(self): + user = create_user(prefix="recipient") + create_invite(user=user) + create_invite() + create_invite(is_accepted=True) + self.client.force_authenticate(user) + + response = self.client.get("/invites/", {"user": "any"}) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_project_leader_can_list_project_invites_by_project_filter(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + first_invite = create_invite(project=project) + second_invite = create_invite(project=project) + create_invite() + self.client.force_authenticate(leader) + + response = self.client.get("/invites/", {"project": project.id}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + {item["id"] for item in response.data}, + {first_invite.id, second_invite.id}, + ) + + def test_project_leader_cannot_use_user_any_filter(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + create_invite(project=project) + self.client.force_authenticate(leader) + + response = self.client.get( + "/invites/", + {"project": project.id, "user": "any"}, + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_project_leader_can_filter_project_invites_by_invited_user(self): + leader = create_user(prefix="leader") + project = create_project(leader=leader) + recipient = create_user(prefix="recipient") + target_invite = create_invite(project=project, user=recipient) + create_invite(project=project) + create_invite() + self.client.force_authenticate(leader) + + response = self.client.get( + "/invites/", + {"project": project.id, "user": recipient.id}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual([item["id"] for item in response.data], [target_invite.id]) + + def test_staff_can_list_all_active_invites_with_user_any(self): + staff = create_user(prefix="staff", is_staff=True) + first_invite = create_invite() + second_invite = create_invite() + create_invite(is_accepted=True) + self.client.force_authenticate(staff) + + response = self.client.get("/invites/", {"user": "any"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + {item["id"] for item in response.data}, + {first_invite.id, second_invite.id}, + ) + + def test_anonymous_user_cannot_list_invites(self): + create_invite() + + response = self.client.get("/invites/") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/invites/tests/test_invite_decisions.py b/invites/tests/test_invite_decisions.py new file mode 100644 index 00000000..0af8a681 --- /dev/null +++ b/invites/tests/test_invite_decisions.py @@ -0,0 +1,126 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from projects.models import Collaborator +from invites.tests.helpers import create_invite, create_user + + +class InviteDecisionAPITests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_invited_user_can_accept_invite_and_become_collaborator(self): + recipient = create_user(prefix="recipient") + invite = create_invite( + user=recipient, + role="Analyst", + specialization="Market research", + ) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/accept/") + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(invite.is_accepted) + self.assertTrue( + Collaborator.objects.filter( + project=invite.project, + user=recipient, + role="Analyst", + specialization="Market research", + ).exists() + ) + + def test_invited_user_can_decline_invite(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(invite.is_accepted) + + def test_other_user_cannot_accept_or_decline_invite(self): + invite = create_invite() + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + accept_response = self.client.post(f"/invites/{invite.id}/accept/") + decline_response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(accept_response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(decline_response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIsNone(invite.is_accepted) + + def test_project_leader_cannot_accept_or_decline_invite(self): + invite = create_invite() + self.client.force_authenticate(invite.project.leader) + + accept_response = self.client.post(f"/invites/{invite.id}/accept/") + decline_response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(accept_response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(decline_response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIsNone(invite.is_accepted) + + def test_accepting_already_accepted_invite_returns_conflict(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient, is_accepted=True) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/accept/") + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + def test_accepting_declined_invite_returns_conflict(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient, is_accepted=False) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/accept/") + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + def test_declining_already_accepted_invite_returns_conflict(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient, is_accepted=True) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertTrue(invite.is_accepted) + + def test_declining_already_declined_invite_returns_conflict(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient, is_accepted=False) + self.client.force_authenticate(recipient) + + response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + self.assertFalse(invite.is_accepted) + + def test_decline_after_accept_returns_conflict_and_keeps_collaborator(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient) + self.client.force_authenticate(recipient) + + accept_response = self.client.post(f"/invites/{invite.id}/accept/") + decline_response = self.client.post(f"/invites/{invite.id}/decline/") + + invite.refresh_from_db() + self.assertEqual(accept_response.status_code, status.HTTP_200_OK) + self.assertEqual(decline_response.status_code, status.HTTP_409_CONFLICT) + self.assertTrue(invite.is_accepted) + self.assertTrue( + Collaborator.objects.filter(project=invite.project, user=recipient).exists() + ) diff --git a/invites/tests/test_invite_detail_access.py b/invites/tests/test_invite_detail_access.py new file mode 100644 index 00000000..a6ab7c77 --- /dev/null +++ b/invites/tests/test_invite_detail_access.py @@ -0,0 +1,142 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from invites.models import Invite +from invites.tests.helpers import create_invite, create_user + + +class InviteDetailAccessTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_anonymous_user_cannot_read_invite_detail(self): + invite = create_invite() + + response = self.client.get(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_invited_user_can_read_invite_detail(self): + recipient = create_user(prefix="recipient") + invite = create_invite(user=recipient) + self.client.force_authenticate(recipient) + + response = self.client.get(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], invite.id) + + def test_project_leader_can_read_invite_detail(self): + invite = create_invite() + self.client.force_authenticate(invite.project.leader) + + response = self.client.get(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], invite.id) + + def test_staff_can_read_invite_detail(self): + staff = create_user(prefix="staff", is_staff=True) + invite = create_invite() + self.client.force_authenticate(staff) + + response = self.client.get(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], invite.id) + + def test_outsider_cannot_read_invite_detail(self): + invite = create_invite() + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + response = self.client.get(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_project_leader_can_update_invite(self): + invite = create_invite(motivational_letter="Initial") + self.client.force_authenticate(invite.project.leader) + + response = self.client.patch( + f"/invites/{invite.id}/", + {"motivational_letter": "Changed by leader"}, + format="json", + ) + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(invite.motivational_letter, "Changed by leader") + + def test_invited_user_cannot_update_invite(self): + invite = create_invite(motivational_letter="Initial") + self.client.force_authenticate(invite.user) + + response = self.client.patch( + f"/invites/{invite.id}/", + {"motivational_letter": "Changed by recipient"}, + format="json", + ) + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(invite.motivational_letter, "Initial") + + def test_staff_cannot_update_invite(self): + staff = create_user(prefix="staff", is_staff=True) + invite = create_invite(motivational_letter="Initial") + self.client.force_authenticate(staff) + + response = self.client.patch( + f"/invites/{invite.id}/", + {"motivational_letter": "Changed by staff"}, + format="json", + ) + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(invite.motivational_letter, "Initial") + + def test_outsider_cannot_update_invite(self): + invite = create_invite(motivational_letter="Initial") + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + response = self.client.patch( + f"/invites/{invite.id}/", + {"motivational_letter": "Changed by outsider"}, + format="json", + ) + + invite.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(invite.motivational_letter, "Initial") + + def test_project_leader_can_delete_invite(self): + invite = create_invite() + self.client.force_authenticate(invite.project.leader) + + response = self.client.delete(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Invite.objects.filter(pk=invite.pk).exists()) + + def test_invited_user_cannot_delete_invite(self): + invite = create_invite() + self.client.force_authenticate(invite.user) + + response = self.client.delete(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(Invite.objects.filter(pk=invite.pk).exists()) + + def test_outsider_cannot_delete_invite(self): + invite = create_invite() + outsider = create_user(prefix="outsider") + self.client.force_authenticate(outsider) + + response = self.client.delete(f"/invites/{invite.id}/") + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(Invite.objects.filter(pk=invite.pk).exists()) diff --git a/invites/tests/test_models.py b/invites/tests/test_models.py new file mode 100644 index 00000000..ea6b2d0a --- /dev/null +++ b/invites/tests/test_models.py @@ -0,0 +1,16 @@ +from django.test import TestCase + +from invites.tests.helpers import create_invite + + +class InviteModelTests(TestCase): + def test_string_representation_contains_project_and_user(self): + invite = create_invite() + + self.assertEqual( + str(invite), + ( + f'Invite from project "{invite.project.name}" ' + f"to {invite.user.get_full_name()}" + ), + ) diff --git a/invites/views.py b/invites/views.py index a365fff3..aeb5edfd 100644 --- a/invites/views.py +++ b/invites/views.py @@ -4,17 +4,23 @@ from invites.filters import InviteFilter from invites.models import Invite +from invites.permissions import InviteDecisionPermission, InviteDetailPermission +from invites.querysets import get_visible_invites_queryset from invites.serializers import InviteDetailSerializer, InviteListSerializer from projects.models import Collaborator class InviteList(generics.ListCreateAPIView): - queryset = Invite.objects.get_invite_for_list_view().filter(is_accepted__isnull=True) serializer_class = InviteDetailSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + permission_classes = [permissions.IsAuthenticated] filter_backends = (filters.DjangoFilterBackend,) filterset_class = InviteFilter + def get_queryset(self): + return get_visible_invites_queryset(self.request.user).filter( + is_accepted__isnull=True + ) + def create(self, request, *args, **kwargs): serializer = InviteListSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -33,28 +39,21 @@ def create(self, request, *args, **kwargs): class InviteDetail(generics.RetrieveUpdateDestroyAPIView): - queryset = Invite.objects.all() + queryset = Invite.objects.get_invite_for_list_view() serializer_class = InviteDetailSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + permission_classes = [InviteDetailPermission] class InviteAccept(generics.GenericAPIView): - queryset = Invite.objects.all() + queryset = Invite.objects.get_invite_for_list_view() serializer_class = InviteDetailSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + permission_classes = [InviteDecisionPermission] def post(self, request, *args, **kwargs): invite = self.get_object() # type: Invite - if invite.user != request.user: - return Response(status=status.HTTP_403_FORBIDDEN) - if invite.is_accepted is True: - return Response( - {"detail": "Invite has already been accepted."}, - status=status.HTTP_409_CONFLICT, - ) - if invite.is_accepted is False: + if invite.is_accepted is not None: return Response( - {"detail": "Invite has already been declined."}, + {"detail": "Invite has already been processed."}, status=status.HTTP_409_CONFLICT, ) # add user to project collaborators @@ -77,14 +76,17 @@ def post(self, request, *args, **kwargs): class InviteDecline(generics.GenericAPIView): - queryset = Invite.objects.all() + queryset = Invite.objects.get_invite_for_list_view() serializer_class = InviteDetailSerializer - permission_classes = [permissions.IsAuthenticatedOrReadOnly] + permission_classes = [InviteDecisionPermission] def post(self, request, *args, **kwargs): invite = self.get_object() - if invite.user != request.user: - return Response(status=status.HTTP_403_FORBIDDEN) + if invite.is_accepted is not None: + return Response( + {"detail": "Invite has already been processed."}, + status=status.HTTP_409_CONFLICT, + ) invite.is_accepted = False invite.save() return Response(status=status.HTTP_200_OK) From ded499a1bd5f479570ac896b24985edd0278a38d Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 10 Jun 2026 09:47:59 +0500 Subject: [PATCH 28/32] =?UTF-8?q?fix:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B2=D1=8B=D1=8F=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80=D0=BE=D1=80=D0=B1?= =?UTF-8?q?=D0=BB=D0=B5=D0=BC=D1=8B=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - увеличен лимит размера запроса в dev nginx до значения prod уровня - исправлен фильтр приглашений по project для строковых query-значений - добавлен regression-тест на создание приглашения и последующее получение списка приглашений - переименован раздел проектов в админке партнёрских программ --- deploy/nginx/host/dev/dev.procollab.ru | 3 ++ invites/filters.py | 9 ++++- invites/tests/test_invite_api.py | 37 +++++++++++++++++++ ...017_alter_partnerprogramproject_options.py | 20 ++++++++++ partner_programs/models.py | 4 +- 5 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 partner_programs/migrations/0017_alter_partnerprogramproject_options.py diff --git a/deploy/nginx/host/dev/dev.procollab.ru b/deploy/nginx/host/dev/dev.procollab.ru index d1b13028..7f5b4a34 100644 --- a/deploy/nginx/host/dev/dev.procollab.ru +++ b/deploy/nginx/host/dev/dev.procollab.ru @@ -2,6 +2,7 @@ server { listen 80; server_name dev.procollab.ru; server_tokens off; + client_max_body_size 100M; location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; @@ -24,6 +25,8 @@ server { ssl_certificate /etc/letsencrypt/live/dev.procollab.ru-0001/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/dev.procollab.ru-0001/privkey.pem; + client_max_body_size 100M; + location ~ ^/(admin|api-auth|files|industries|news|projects|vacancies|core|invites|auth|chats|events|programs|courses|rate-project|feed|api|anymail|ws)(/|$) { include /etc/nginx/procollab/includes/proxy_app.inc; } diff --git a/invites/filters.py b/invites/filters.py index 9c62215d..7fa6edf4 100644 --- a/invites/filters.py +++ b/invites/filters.py @@ -2,7 +2,6 @@ from rest_framework.exceptions import PermissionDenied from invites.models import Invite -from vacancy.filters import project_id_filter def _first_filter_value(value): @@ -40,6 +39,12 @@ def __init__(self, *args, **kwargs): if user_value is None and project_value in (None, ""): self.data["user"] = request.user.id + def filter_project(self, queryset, name, value): + value = _first_filter_value(value) + if value in (None, ""): + return queryset + return queryset.filter(project_id=value) + def filter_user(self, queryset, name, value): value = _first_filter_value(value) if value == "any": @@ -51,7 +56,7 @@ def filter_user(self, queryset, name, value): return queryset return queryset.filter(user_id=value) - project = filters.Filter(method=project_id_filter) + project = filters.Filter(method="filter_project") user = filters.Filter(method="filter_user") class Meta: diff --git a/invites/tests/test_invite_api.py b/invites/tests/test_invite_api.py index faf8508f..dd2816cb 100644 --- a/invites/tests/test_invite_api.py +++ b/invites/tests/test_invite_api.py @@ -1,7 +1,10 @@ +from types import SimpleNamespace + from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient +from invites.filters import InviteFilter from invites.models import Invite from invites.tests.helpers import ( add_collaborator, @@ -224,6 +227,40 @@ def test_project_leader_can_list_project_invites_by_project_filter(self): {first_invite.id, second_invite.id}, ) + def test_project_leader_can_list_invite_after_creating_via_api(self): + leader = create_user(prefix="leader") + recipient = create_user(prefix="recipient") + project = create_project(leader=leader) + self.client.force_authenticate(leader) + + create_response = self.client.post( + "/invites/", + invite_payload(project, recipient), + format="json", + ) + list_response = self.client.get("/invites/", {"project": project.id}) + + self.assertEqual(create_response.status_code, status.HTTP_201_CREATED) + self.assertEqual(list_response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item["id"] for item in list_response.data], + [create_response.data["id"]], + ) + + def test_project_filter_handles_scalar_multi_digit_project_id(self): + leader = create_user(prefix="leader") + projects = [create_project(leader=leader) for _ in range(12)] + target_project = projects[-1] + invite = create_invite(project=target_project) + + filtered = InviteFilter( + data={"project": str(target_project.id)}, + queryset=Invite.objects.all(), + request=SimpleNamespace(user=leader), + ).qs + + self.assertEqual(list(filtered), [invite]) + def test_project_leader_cannot_use_user_any_filter(self): leader = create_user(prefix="leader") project = create_project(leader=leader) diff --git a/partner_programs/migrations/0017_alter_partnerprogramproject_options.py b/partner_programs/migrations/0017_alter_partnerprogramproject_options.py new file mode 100644 index 00000000..9c33edfe --- /dev/null +++ b/partner_programs/migrations/0017_alter_partnerprogramproject_options.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2026-06-10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("partner_programs", "0016_partnerprogram_is_distributed_evaluation"), + ] + + operations = [ + migrations.AlterModelOptions( + name="partnerprogramproject", + options={ + "verbose_name": "Проект", + "verbose_name_plural": "Проекты", + }, + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index b1cf5679..33c6abe6 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -307,8 +307,8 @@ def can_edit(self, user: User) -> bool: class Meta: unique_together = ("partner_program", "project") - verbose_name = "Проект участующий в программе" - verbose_name_plural = "Проекеты участвующие в программах" + verbose_name = "Проект" + verbose_name_plural = "Проекты" def __str__(self): return f"{self.project} в программе {self.partner_program}" From 0eccd3dee30679710720eab6ce61ed01057ca0ac Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 10 Jun 2026 10:23:30 +0500 Subject: [PATCH 29/32] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20Metrics=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=20=D1=81=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B8=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/metrics.md | 96 ++++++++++++++++++++++++- metrics/services.py | 32 +++++++++ metrics/{tests.py => tests/__init__.py} | 0 metrics/tests/helpers.py | 47 ++++++++++++ metrics/tests/test_metrics_api.py | 41 +++++++++++ metrics/tests/test_metrics_services.py | 53 ++++++++++++++ metrics/views.py | 42 +---------- 7 files changed, 270 insertions(+), 41 deletions(-) create mode 100644 metrics/services.py rename metrics/{tests.py => tests/__init__.py} (100%) create mode 100644 metrics/tests/helpers.py create mode 100644 metrics/tests/test_metrics_api.py create mode 100644 metrics/tests/test_metrics_services.py diff --git a/docs/modules/metrics.md b/docs/modules/metrics.md index b4301618..b40fbdf4 100644 --- a/docs/modules/metrics.md +++ b/docs/modules/metrics.md @@ -1,3 +1,97 @@ # Metrics -TODO +## Назначение + +Metrics отвечает за внутренний admin-only endpoint с базовыми счетчиками +сервиса. + +Модуль нужен для быстрой технической сводки: сколько в системе пользователей, +ролевых профилей, проектов, вакансий и сколько пользователей сейчас считаются +online через websocket-чаты. + +## Статус модуля + +Модуль рабочий и подключен в корень API через `GET /`. + +Endpoint доступен только staff-пользователям. Для anonymous и обычных +authenticated пользователей доступ закрыт. + +## Основные возможности + +- подсчет общего количества пользователей; +- подсчет количества ролевых профилей пользователей; +- подсчет количества проектов; +- подсчет количества вакансий; +- подсчет текущих online-пользователей по cache-ключу websocket-чата. + +## Архитектура + +- `metrics/views.py` - HTTP endpoint метрик. +- `metrics/services.py` - сбор response payload. +- `metrics/urls.py` - route модуля. +- `metrics/tests/` - regression-тесты и helpers модуля. + +## API + +- `GET /` - получить внутренние метрики сервиса. + +Response содержит поля: + +- `total_CustomUser_count` - количество пользователей; +- `total_Expert_count` - количество экспертных профилей; +- `total_Investor_count` - количество профилей инвесторов; +- `total_Member_count` - количество профилей участников; +- `total_Mentor_count` - количество профилей менторов; +- `total_Project_count` - количество проектов; +- `total_Vacancy_count` - количество вакансий; +- `current_online_users` - количество пользователей online по данным cache. + +## Основные сценарии + +### Staff открывает внутреннюю сводку + +Staff-пользователь отправляет `GET /` и получает текущие счетчики. + +Счетчики `total_*_count` считаются через `objects.count()` соответствующих +моделей. + +### Подсчет online-пользователей + +`current_online_users` считается по cache-ключу `online_users`. + +Этот ключ наполняется модулем `chats`: при подключении пользователя к +websocket-чату пользователь добавляется в set online-пользователей, при +отключении удаляется. + +## Связи с другими модулями + +- `users` - счетчики пользователей и ролевых профилей. +- `projects` - счетчик проектов. +- `vacancy` - счетчик вакансий. +- `chats` - источник данных для `current_online_users`. +- `core` - cache helpers для online-ключей. + +## Ограничения и риски + +- Endpoint подключен к корню API: `GET /`. Это текущий контракт, но он + неочевиден для отдельного модуля метрик. +- `current_online_users` показывает только пользователей, которые считаются + online через websocket-чаты. Пользователь, который делает только HTTP-запросы, + в этот счетчик не попадет. +- Online-счетчик зависит от cache. После очистки cache значение будет `0`, пока + пользователи снова не подключатся к websocket. +- Поля response имеют технические имена моделей и сохранены для совместимости. + +## Тесты + +Текущие тесты лежат в `metrics/tests/`. + +Проверяется: + +- anonymous пользователь не получает доступ к метрикам; +- обычный authenticated пользователь не получает доступ к метрикам; +- staff-пользователь получает payload метрик; +- service считает пользователей, ролевые профили, проекты, вакансии и + online-пользователей; +- пустой online-cache возвращает `current_online_users = 0`; +- helper подсчета модели сохраняет уже собранный payload. diff --git a/metrics/services.py b/metrics/services.py new file mode 100644 index 00000000..f9ac78da --- /dev/null +++ b/metrics/services.py @@ -0,0 +1,32 @@ +from django.contrib.auth import get_user_model +from django.core.cache import cache + +from core.utils import get_users_online_cache_key +from projects.models import Project +from users.models import Expert, Investor, Member, Mentor +from vacancy.models import Vacancy + +User = get_user_model() + +METRIC_MODELS = (User, Expert, Investor, Member, Mentor, Project, Vacancy) + + +def add_total_count(data: dict[str, int], model) -> dict[str, int]: + new_data = dict(data) + new_data[f"total_{model.__name__}_count"] = model.objects.count() + return new_data + + +def get_current_online_users_count() -> int: + users_online_list_key = get_users_online_cache_key() + return len(cache.get_or_set(users_online_list_key, set())) + + +def collect_metrics_payload() -> dict[str, int]: + data = {} + + for model in METRIC_MODELS: + data = add_total_count(data, model) + + data["current_online_users"] = get_current_online_users_count() + return data diff --git a/metrics/tests.py b/metrics/tests/__init__.py similarity index 100% rename from metrics/tests.py rename to metrics/tests/__init__.py diff --git a/metrics/tests/helpers.py b/metrics/tests/helpers.py new file mode 100644 index 00000000..d03213ad --- /dev/null +++ b/metrics/tests/helpers.py @@ -0,0 +1,47 @@ +from datetime import date +from uuid import uuid4 + +from projects.models import Project +from users.models import CustomUser +from vacancy.models import Vacancy + + +def unique_suffix() -> str: + return uuid4().hex[:8] + + +def create_user( + *, + prefix: str = "metrics-user", + is_staff: bool = False, + is_superuser: bool = False, + user_type: int = CustomUser.MEMBER, +) -> CustomUser: + return CustomUser.objects.create_user( + email=f"{prefix}-{unique_suffix()}@example.com", + password="test_password_123", + first_name="Иван", + last_name="Иванов", + birthday=date(2000, 1, 1), + user_type=user_type, + is_active=True, + is_staff=is_staff or is_superuser, + is_superuser=is_superuser, + ) + + +def create_project(*, leader: CustomUser | None = None) -> Project: + return Project.objects.create( + leader=leader or create_user(prefix="metrics-leader"), + name=f"Metrics project {unique_suffix()}", + description="Проект для метрик", + draft=False, + ) + + +def create_vacancy(*, project: Project | None = None) -> Vacancy: + return Vacancy.objects.create( + project=project or create_project(), + role=f"Metrics vacancy {unique_suffix()}", + description="Вакансия для метрик", + ) diff --git a/metrics/tests/test_metrics_api.py b/metrics/tests/test_metrics_api.py new file mode 100644 index 00000000..cbd0cf96 --- /dev/null +++ b/metrics/tests/test_metrics_api.py @@ -0,0 +1,41 @@ +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient + +from core.utils import get_users_online_cache_key +from metrics.tests.helpers import create_project, create_user, create_vacancy + + +class MetricsAPITests(TestCase): + def setUp(self): + self.client = APIClient() + cache.clear() + + def test_anonymous_user_cannot_access_metrics(self): + response = self.client.get("/") + + self.assertEqual(response.status_code, 401) + + def test_staff_user_can_get_metrics_payload(self): + staff = create_user(prefix="metrics-staff", is_staff=True) + user = create_user(prefix="metrics-regular") + project = create_project(leader=user) + create_vacancy(project=project) + cache.set(get_users_online_cache_key(), {staff.id, user.id}) + self.client.force_authenticate(staff) + + response = self.client.get("/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["total_CustomUser_count"], 2) + self.assertEqual(response.data["total_Project_count"], 1) + self.assertEqual(response.data["total_Vacancy_count"], 1) + self.assertEqual(response.data["current_online_users"], 2) + + def test_non_staff_user_cannot_access_metrics(self): + user = create_user(prefix="metrics-regular") + self.client.force_authenticate(user) + + response = self.client.get("/") + + self.assertEqual(response.status_code, 403) diff --git a/metrics/tests/test_metrics_services.py b/metrics/tests/test_metrics_services.py new file mode 100644 index 00000000..dfdf9472 --- /dev/null +++ b/metrics/tests/test_metrics_services.py @@ -0,0 +1,53 @@ +from django.core.cache import cache +from django.test import TestCase + +from core.utils import get_users_online_cache_key +from metrics.services import add_total_count, collect_metrics_payload +from metrics.tests.helpers import create_project, create_user, create_vacancy +from users.models import CustomUser + + +class MetricsServiceTests(TestCase): + def setUp(self): + cache.clear() + + def test_collect_metrics_payload_counts_supported_models_and_online_users(self): + online_user = create_user( + prefix="metrics-online", + user_type=CustomUser.ADMIN, + ) + create_user(prefix="metrics-member") + create_user(prefix="metrics-mentor", user_type=CustomUser.MENTOR) + create_user(prefix="metrics-expert", user_type=CustomUser.EXPERT) + create_user(prefix="metrics-investor", user_type=CustomUser.INVESTOR) + leader = create_user( + prefix="metrics-leader", + user_type=CustomUser.ADMIN, + ) + project = create_project(leader=leader) + create_vacancy(project=project) + cache.set(get_users_online_cache_key(), {online_user.id, leader.id}) + + payload = collect_metrics_payload() + + self.assertEqual(payload["total_CustomUser_count"], 6) + self.assertEqual(payload["total_Member_count"], 1) + self.assertEqual(payload["total_Mentor_count"], 1) + self.assertEqual(payload["total_Expert_count"], 1) + self.assertEqual(payload["total_Investor_count"], 1) + self.assertEqual(payload["total_Project_count"], 1) + self.assertEqual(payload["total_Vacancy_count"], 1) + self.assertEqual(payload["current_online_users"], 2) + + def test_collect_metrics_payload_returns_zero_online_users_for_empty_cache(self): + payload = collect_metrics_payload() + + self.assertEqual(payload["current_online_users"], 0) + + def test_add_total_count_preserves_existing_payload(self): + create_user(prefix="metrics-counted") + + payload = add_total_count({"existing": 1}, CustomUser) + + self.assertEqual(payload["existing"], 1) + self.assertEqual(payload["total_CustomUser_count"], 1) diff --git a/metrics/views.py b/metrics/views.py index 8bb1311c..0db70580 100644 --- a/metrics/views.py +++ b/metrics/views.py @@ -1,14 +1,8 @@ -from django.contrib.auth import get_user_model -from core.utils import get_users_online_cache_key -from projects.models import Project from rest_framework import permissions from rest_framework.response import Response from rest_framework.views import APIView -from users.models import Expert, Investor, Member, Mentor -from vacancy.models import Vacancy -from django.core.cache import cache -User = get_user_model() +from metrics.services import collect_metrics_payload class MetricsView(APIView): @@ -21,36 +15,4 @@ class MetricsView(APIView): permission_classes = [permissions.IsAdminUser] def get(self, request, format=None): - data = {} - - models = [User, Expert, Investor, Member, Mentor, Project, Vacancy] - - for model in models: - data = self._update_total_counts(data, model) - - users_online_list_key = get_users_online_cache_key() - data["current_online_users"] = len(cache.get_or_set(users_online_list_key, set())) - - return Response(data) - - def _update_total_counts(self, data, model) -> dict[str, int]: - """ - Updates the total counts of the given model. - - Args: - data: dict with data. - model: model to get count from. - - Returns: - dict: A dictionary with the updated data. - - For example: - { - "total_Investor_count": 3, - } - """ - - new_data = dict(data) - new_data[f"total_{model.__name__}_count"] = model.objects.count() - - return new_data + return Response(collect_metrics_payload()) From d9e4a41ef03af9690b6370a860fe68c5c1a3b33b Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 10 Jun 2026 10:41:06 +0500 Subject: [PATCH 30/32] =?UTF-8?q?=D0=97=D0=B0=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20Mailing=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D1=8B=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/mailing.md | 138 ++++++++++++- mailing/tests/__init__.py | 0 mailing/tests/helpers.py | 72 +++++++ mailing/tests/test_models_rendering.py | 84 ++++++++ .../test_program_scenarios.py} | 189 ++++++++---------- mailing/tests/test_utils.py | 110 ++++++++++ 6 files changed, 490 insertions(+), 103 deletions(-) create mode 100644 mailing/tests/__init__.py create mode 100644 mailing/tests/helpers.py create mode 100644 mailing/tests/test_models_rendering.py rename mailing/{tests.py => tests/test_program_scenarios.py} (51%) create mode 100644 mailing/tests/test_utils.py diff --git a/docs/modules/mailing.md b/docs/modules/mailing.md index 3efbc016..57ccbd2d 100644 --- a/docs/modules/mailing.md +++ b/docs/modules/mailing.md @@ -1,3 +1,139 @@ # Mailing -TODO +## Назначение + +Модуль `mailing` отвечает за email-рассылки и шаблоны писем: хранение схем +старой админской формы рассылки, подготовку данных письма, отправку сообщений +через email backend и автоматические сценарии рассылок по партнерским +программам. + +## Статус модуля + +Модуль используется в рабочих сценариях, но не подключен как публичный API. +Основные активные точки использования: + +- celery-задача `run_program_mailings`; +- старая форма рассылки из админки партнерских программ; +- общие helper-функции отправки писем, которые используют другие модули. + +## Основные возможности + +- хранение схемы письма в `MailingSchema`; +- рендеринг старой админской формы рассылки; +- подготовка данных письма из формы или typed dataclass; +- массовая отправка писем по строковому шаблону; +- массовая отправка писем по Django template; +- группировка писем батчами; +- сценарные рассылки участникам партнерских программ; +- логирование результата сценарных рассылок в `MailingScenarioLog`. + +## Архитектура + +- `mailing/models.py` - модели схем писем и логов сценарных рассылок. +- `mailing/utils.py` - подготовка данных письма и низкоуровневые функции + отправки. +- `mailing/scenarios.py` - декларативное описание сценариев рассылки по + программам. +- `mailing/tasks.py` - celery-задача запуска сценариев. +- `mailing/rendering.py` - подстановка базовых placeholders в темы и тексты. +- `mailing/views.py` - старые views для формы рассылки; сейчас не подключены в + публичный URLConf. +- `mailing/urls.py` - старые routes формы рассылки, не подключенные в + `procollab/urls.py`. +- `mailing/tests/` - regression-тесты моделей, rendering/helpers и сценариев. + +## Ключевые сущности + +- `MailingSchema` - схема шаблона письма и HTML-шаблон для старой формы + рассылки. +- `MailingScenarioLog` - лог отправки сценарного письма конкретному участнику + программы за конкретную дату. +- `Scenario` - dataclass с кодом сценария, триггером, правилом выбора + получателей, шаблоном и builder-контекстом. +- `EmailDataToPrepare` - typed input для подготовки данных письма из кода. + +## API и внешние точки входа + +Публичных endpoints модуля `mailing` сейчас нет: `mailing.urls` не подключен в +корневой `procollab/urls.py`. + +Связанные внешние точки: + +- `/anymail/` - webhook routes библиотеки Anymail; +- админка партнерской программы вызывает `MailingTemplateRender` напрямую через + custom admin view; +- celery beat запускает `mailing.tasks.run_program_mailings` каждый день в + 10:00. + +## Основные сценарии + +### 1. Сценарная рассылка по партнерским программам + +`run_program_mailings()` проходит по сценариям из `SCENARIOS`. + +Для каждого сценария: + +- вычисляется целевая дата; +- выбираются программы по дате регистрации, окончанию регистрации или дедлайну + подачи проекта; +- выбираются получатели по правилу сценария; +- создаются `MailingScenarioLog` в статусе `pending`; +- письмо отправляется через `send_mass_mail_from_template`; +- статус лога меняется на `sent` или `failed` по `anymail_status`. + +Повторная отправка за ту же дату не дублирует письма со статусом `pending` или +`sent`. + +### 2. Старая админская рассылка + +`MailingTemplateRender` строит контекст формы: + +- доступные `MailingSchema`; +- выбранные и невыбранные пользователи; +- поля шаблона из JSON-схемы. + +Сейчас этот renderer используется из админки партнерских программ. + +### 3. Отправка письма из других модулей + +Другие модули могут подготовить `EmailDataToPrepare`, получить данные через +`prepare_mail_data()` и отправить письмо через `send_mass_mail()`. + +Такой flow сейчас использует `vacancy.tasks.send_email`, который также +переиспользуется партнерскими программами и оценками проектов. + +## Связи с другими модулями + +- `partner_programs` - сценарные рассылки выбирают программы и участников через + selectors; админка программ использует старый renderer формы рассылки. +- `vacancy` - задачи вакансий используют mailing helpers для email-уведомлений. +- `project_rates` - переиспользует общий notification flow через + `vacancy.tasks.send_email`. +- `users` - получатели писем. +- `anymail` / Unisender Go - фактическая отправка писем в production. + +## Ограничения и риски + +- `mailing/urls.py` содержит старые routes, но они не подключены наружу. +- Если старые routes будут снова подключены, для них нужно отдельно проверить + permissions и безопасность массовой отправки. +- В `mailing/urls.py` есть историческая опечатка `template_fileds`; менять ее + без проверки старого UI не стоит. +- `vacancy.tasks.send_email` фактически является общим helper для уведомлений, + но находится в модуле вакансий. +- `MailingScenarioLog` пока не зарегистрирован в Django admin. + +## Тесты + +Текущие regression-тесты проверяют: + +- строковое представление `MailingSchema` и `MailingScenarioLog`; +- подстановку placeholders в subject и template values; +- контекст старого renderer формы рассылки; +- подготовку данных письма из `EmailDataToPrepare`; +- группировку писем батчами; +- рендеринг и отправку писем по строковому шаблону; +- отправку писем по Django template с `status_callback`; +- выбор участников с неактивными аккаунтами для сценариев программ; +- успешную сценарную рассылку без повторной отправки; +- перевод сценарного лога в `failed` при ошибочном `anymail_status`. diff --git a/mailing/tests/__init__.py b/mailing/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mailing/tests/helpers.py b/mailing/tests/helpers.py new file mode 100644 index 00000000..a54f5c92 --- /dev/null +++ b/mailing/tests/helpers.py @@ -0,0 +1,72 @@ +from datetime import datetime, time, timedelta + +from django.utils import timezone + +from mailing.models import MailingSchema +from partner_programs.models import PartnerProgram, PartnerProgramUserProfile +from users.models import CustomUser + + +def aware_datetime(dt_date, hour: int = 12): + return timezone.make_aware( + datetime.combine(dt_date, time(hour=hour)), + timezone.get_current_timezone(), + ) + + +def create_user(email: str = "mailing-user@example.com", **overrides) -> CustomUser: + defaults = { + "email": email, + "password": "test-password-12345", + "first_name": "Test", + "last_name": "User", + "birthday": "2000-01-01", + "is_active": True, + } + defaults.update(overrides) + return CustomUser.objects.create_user(**defaults) + + +def create_program(**overrides) -> PartnerProgram: + today = timezone.localdate() + defaults = { + "name": "Mailing Program", + "tag": "mailing-program", + "city": "Moscow", + "datetime_registration_ends": aware_datetime(today + timedelta(days=10)), + "datetime_started": aware_datetime(today - timedelta(days=10)), + "datetime_finished": aware_datetime(today + timedelta(days=40)), + } + defaults.update(overrides) + return PartnerProgram.objects.create(**defaults) + + +def register_program_user( + user: CustomUser, + program: PartnerProgram, + registered_on, +) -> PartnerProgramUserProfile: + profile = PartnerProgramUserProfile.objects.create( + user=user, + partner_program=program, + partner_program_data={}, + ) + PartnerProgramUserProfile.objects.filter(id=profile.id).update( + datetime_created=aware_datetime(registered_on) + ) + profile.refresh_from_db() + return profile + + +def create_mailing_schema(**overrides) -> MailingSchema: + defaults = { + "name": "Default mailing schema", + "schema": { + "title": {"title": "Title", "default": "Default title"}, + "text": {"title": "Text"}, + "button_text": {"title": "Button text", "default": "Open"}, + }, + "template": "

{{ title }}

{{ text }}

{{ user.email }}", + } + defaults.update(overrides) + return MailingSchema.objects.create(**defaults) diff --git a/mailing/tests/test_models_rendering.py b/mailing/tests/test_models_rendering.py new file mode 100644 index 00000000..f0f19dfe --- /dev/null +++ b/mailing/tests/test_models_rendering.py @@ -0,0 +1,84 @@ +from django.test import TestCase +from django.utils import timezone + +from mailing.models import MailingScenarioLog +from mailing.rendering import render_subject, render_template_value +from mailing.views import MailingTemplateRender + +from .helpers import create_mailing_schema, create_program, create_user + + +class MailingModelsTests(TestCase): + def test_mailing_schema_string_representation(self): + schema = create_mailing_schema(name="Program reminder") + + self.assertEqual(str(schema), "MailingSchema") + + def test_mailing_scenario_log_string_representation(self): + program = create_program() + user = create_user() + log = MailingScenarioLog.objects.create( + scenario_code="program_registration_plus_3_inactive_account", + program=program, + user=user, + scheduled_for=timezone.localdate(), + status=MailingScenarioLog.Status.PENDING, + ) + + self.assertIn("program_registration_plus_3_inactive_account", str(log)) + self.assertIn(f"program={program.id}", str(log)) + self.assertIn(f"user={user.id}", str(log)) + self.assertIn("status=pending", str(log)) + + +class MailingRenderingTests(TestCase): + def test_render_subject_replaces_program_name(self): + program = create_program(name="Case Cup") + + subject = render_subject("{program_name}: reminder", program) + + self.assertEqual(subject, "Case Cup: reminder") + + def test_render_template_value_replaces_known_placeholders(self): + program = create_program(name="Case Cup") + user = create_user() + + value = render_template_value( + "/program/{program_id}/users/{user_id}/{program_name}", + program, + user, + ) + + self.assertEqual(value, f"/program/{program.id}/users/{user.id}/Case Cup") + + def test_template_render_context_contains_schema_users_and_fields(self): + schema = create_mailing_schema( + name="Participant email", + schema={ + "title": {"title": "Title", "default": "Default title"}, + "text": {"title": "Text"}, + }, + ) + picked_user = create_user(email="picked@example.com") + unpicked_user = create_user(email="unpicked@example.com") + + context = MailingTemplateRender._get_context( + schema.id, + picked_users=[picked_user], + unpicked_users=[unpicked_user], + ) + + selected_schema = context["schemas"][0] + self.assertEqual(selected_schema["id"], schema.id) + self.assertTrue(selected_schema["selected"]) + self.assertEqual(context["picked_users"][0]["id"], picked_user.id) + self.assertTrue(context["picked_users"][0]["picked"]) + self.assertEqual(context["unpicked_users"][0]["id"], unpicked_user.id) + self.assertFalse(context["unpicked_users"][0]["picked"]) + self.assertEqual( + context["template_fields"], + [ + {"key": "title", "title": "Title", "default": "Default title"}, + {"key": "text", "title": "Text", "default": ""}, + ], + ) diff --git a/mailing/tests.py b/mailing/tests/test_program_scenarios.py similarity index 51% rename from mailing/tests.py rename to mailing/tests/test_program_scenarios.py index b724d217..354cc19d 100644 --- a/mailing/tests.py +++ b/mailing/tests/test_program_scenarios.py @@ -1,4 +1,4 @@ -from datetime import datetime, time, timedelta +from datetime import timedelta from unittest.mock import patch from django.test import TestCase @@ -6,23 +6,24 @@ from mailing.models import MailingScenarioLog from mailing.tasks import run_program_mailings -from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from partner_programs.selectors import ( program_participants_with_inactive_account, program_participants_with_inactive_account_registered_on, ) from users.models import CustomUser +from .helpers import aware_datetime, create_program, create_user, register_program_user + class _SentStatus: - def __init__(self, message_id: str): + def __init__(self, message_id: str, status="sent"): self.message_id = message_id - self.status = "sent" + self.status = status class _SentMessage: - def __init__(self, user_id: int): - self.anymail_status = _SentStatus(f"msg-{user_id}") + def __init__(self, user_id: int, status="sent", message_id: str | None = None): + self.anymail_status = _SentStatus(message_id or f"msg-{user_id}", status) def _fake_send_mass_mail_from_template( @@ -38,62 +39,45 @@ def _fake_send_mass_mail_from_template( return len(users) +def _fake_failed_send_mass_mail_from_template( + users, + subject, + template_name, + context_builder=None, + status_callback=None, +): + for user in users: + if status_callback: + status_callback(user, _SentMessage(user.id, status="rejected")) + return len(users) + + class ProgramInactiveAccountSelectorsTests(TestCase): def setUp(self): self.today = timezone.localdate() - def _dt(self, dt_date): - return timezone.make_aware( - datetime.combine(dt_date, time(hour=12)), - timezone.get_current_timezone(), - ) + def test_participants_with_inactive_account(self): + program = create_program() - def _create_user(self, email: str): - return CustomUser.objects.create_user( - email=email, - password="very_strong_password", - first_name="Иван", - last_name="Иванов", - birthday="2000-01-01", - is_active=True, - ) + inactive_no_activity = create_user("inactive-no-activity@example.com") + inactive_old_login = create_user("inactive-old-login@example.com") + active_recent_activity = create_user("active-recent@example.com") - def _create_program(self): - return PartnerProgram.objects.create( - name="FinFor", - tag="finfor", - city="Moscow", - datetime_registration_ends=self._dt(self.today + timedelta(days=10)), - datetime_started=self._dt(self.today - timedelta(days=10)), - datetime_finished=self._dt(self.today + timedelta(days=40)), + register_program_user( + inactive_no_activity, program, self.today - timedelta(days=4) ) - - def _register_user(self, user: CustomUser, program: PartnerProgram, registered_on): - profile = PartnerProgramUserProfile.objects.create( - user=user, - partner_program=program, - partner_program_data={}, + register_program_user( + inactive_old_login, program, self.today - timedelta(days=4) ) - PartnerProgramUserProfile.objects.filter(id=profile.id).update( - datetime_created=self._dt(registered_on) + register_program_user( + active_recent_activity, program, self.today - timedelta(days=4) ) - def test_participants_with_inactive_account(self): - program = self._create_program() - - inactive_no_activity = self._create_user("inactive-no-activity@example.com") - inactive_old_login = self._create_user("inactive-old-login@example.com") - active_recent_activity = self._create_user("active-recent@example.com") - - self._register_user(inactive_no_activity, program, self.today - timedelta(days=4)) - self._register_user(inactive_old_login, program, self.today - timedelta(days=4)) - self._register_user(active_recent_activity, program, self.today - timedelta(days=4)) - CustomUser.objects.filter(id=inactive_old_login.id).update( - last_login=self._dt(self.today - timedelta(days=15)) + last_login=aware_datetime(self.today - timedelta(days=15)) ) CustomUser.objects.filter(id=active_recent_activity.id).update( - last_activity=self._dt(self.today - timedelta(days=1)) + last_activity=aware_datetime(self.today - timedelta(days=1)) ) recipients = program_participants_with_inactive_account( @@ -106,14 +90,18 @@ def test_participants_with_inactive_account(self): self.assertNotIn(active_recent_activity.id, recipient_ids) def test_participants_with_inactive_account_registered_on_date(self): - program = self._create_program() + program = create_program() target_date = self.today - timedelta(days=3) - registered_on_target = self._create_user("registered-on-target@example.com") - registered_other_day = self._create_user("registered-other-day@example.com") + registered_on_target = create_user("registered-on-target@example.com") + registered_other_day = create_user("registered-other-day@example.com") - self._register_user(registered_on_target, program, target_date) - self._register_user(registered_other_day, program, self.today - timedelta(days=2)) + register_program_user(registered_on_target, program, target_date) + register_program_user( + registered_other_day, + program, + self.today - timedelta(days=2), + ) recipients = program_participants_with_inactive_account_registered_on( program.id, target_date, program.datetime_started @@ -128,32 +116,6 @@ class ProgramInactiveAccountScenariosTests(TestCase): def setUp(self): self.today = timezone.localdate() - def _dt(self, dt_date): - return timezone.make_aware( - datetime.combine(dt_date, time(hour=12)), - timezone.get_current_timezone(), - ) - - def _create_user(self, email: str): - return CustomUser.objects.create_user( - email=email, - password="very_strong_password", - first_name="Иван", - last_name="Иванов", - birthday="2000-01-01", - is_active=True, - ) - - def _register_user(self, user: CustomUser, program: PartnerProgram, registered_on): - profile = PartnerProgramUserProfile.objects.create( - user=user, - partner_program=program, - partner_program_data={}, - ) - PartnerProgramUserProfile.objects.filter(id=profile.id).update( - datetime_created=self._dt(registered_on) - ) - @patch( "mailing.tasks.send_mass_mail_from_template", side_effect=_fake_send_mass_mail_from_template, @@ -161,29 +123,27 @@ def _register_user(self, user: CustomUser, program: PartnerProgram, registered_o def test_registration_plus_3_inactive_account_scenario(self, send_mail_mock): target_registration_date = self.today - timedelta(days=3) - program = PartnerProgram.objects.create( + program = create_program( name="FinFor", tag="finfor", - city="Moscow", - datetime_registration_ends=self._dt(self.today + timedelta(days=20)), - datetime_started=self._dt(self.today - timedelta(days=15)), - datetime_finished=self._dt(self.today + timedelta(days=40)), + datetime_registration_ends=aware_datetime(self.today + timedelta(days=20)), + datetime_started=aware_datetime(self.today - timedelta(days=15)), ) - inactive_user = self._create_user("inactive-user@example.com") - active_user = self._create_user("active-user@example.com") - registered_other_day_user = self._create_user("other-day-user@example.com") + inactive_user = create_user("inactive-user@example.com") + active_user = create_user("active-user@example.com") + registered_other_day_user = create_user("other-day-user@example.com") - self._register_user(inactive_user, program, target_registration_date) - self._register_user(active_user, program, target_registration_date) - self._register_user( + register_program_user(inactive_user, program, target_registration_date) + register_program_user(active_user, program, target_registration_date) + register_program_user( registered_other_day_user, program, self.today - timedelta(days=2), ) CustomUser.objects.filter(id=active_user.id).update( - last_activity=self._dt(self.today - timedelta(days=1)) + last_activity=aware_datetime(self.today - timedelta(days=1)) ) sent_count = run_program_mailings() @@ -221,23 +181,22 @@ def test_registration_plus_3_inactive_account_scenario(self, send_mail_mock): def test_registration_end_plus_3_inactive_account_scenario(self, send_mail_mock): target_registration_end_date = self.today - timedelta(days=3) - program = PartnerProgram.objects.create( + program = create_program( name="FinFor", tag="finfor", - city="Moscow", - datetime_registration_ends=self._dt(target_registration_end_date), - datetime_started=self._dt(self.today - timedelta(days=15)), - datetime_finished=self._dt(self.today + timedelta(days=20)), + datetime_registration_ends=aware_datetime(target_registration_end_date), + datetime_started=aware_datetime(self.today - timedelta(days=15)), + datetime_finished=aware_datetime(self.today + timedelta(days=20)), ) - inactive_user = self._create_user("inactive-end-user@example.com") - active_user = self._create_user("active-end-user@example.com") + inactive_user = create_user("inactive-end-user@example.com") + active_user = create_user("active-end-user@example.com") - self._register_user(inactive_user, program, self.today - timedelta(days=10)) - self._register_user(active_user, program, self.today - timedelta(days=10)) + register_program_user(inactive_user, program, self.today - timedelta(days=10)) + register_program_user(active_user, program, self.today - timedelta(days=10)) CustomUser.objects.filter(id=active_user.id).update( - last_login=self._dt(self.today - timedelta(days=1)) + last_login=aware_datetime(self.today - timedelta(days=1)) ) sent_count = run_program_mailings() @@ -267,3 +226,29 @@ def test_registration_end_plus_3_inactive_account_scenario(self, send_mail_mock) all_logs.first().status, MailingScenarioLog.Status.SENT, ) + + @patch( + "mailing.tasks.send_mass_mail_from_template", + side_effect=_fake_failed_send_mass_mail_from_template, + ) + def test_failed_provider_status_marks_scenario_log_failed(self, send_mail_mock): + target_registration_date = self.today - timedelta(days=3) + program = create_program( + datetime_registration_ends=aware_datetime(self.today + timedelta(days=20)), + datetime_started=aware_datetime(self.today - timedelta(days=15)), + ) + inactive_user = create_user("inactive-failed@example.com") + register_program_user(inactive_user, program, target_registration_date) + + sent_count = run_program_mailings() + + self.assertEqual(sent_count, 0) + log = MailingScenarioLog.objects.get( + scenario_code="program_registration_plus_3_inactive_account", + program=program, + scheduled_for=self.today, + user=inactive_user, + ) + self.assertEqual(log.status, MailingScenarioLog.Status.FAILED) + self.assertIn("anymail_status=rejected", log.error) + self.assertEqual(send_mail_mock.call_count, 1) diff --git a/mailing/tests/test_utils.py b/mailing/tests/test_utils.py new file mode 100644 index 00000000..9a45254f --- /dev/null +++ b/mailing/tests/test_utils.py @@ -0,0 +1,110 @@ +from unittest.mock import Mock, patch + +from django.test import TestCase + +from mailing.typing import EmailDataToPrepare +from mailing.utils import ( + create_message_groups, + prepare_mail_data, + send_mass_mail, + send_mass_mail_from_template, +) + +from .helpers import create_mailing_schema, create_user + + +class MailingPrepareDataTests(TestCase): + def test_prepare_mail_data_from_dataclass_uses_schema_and_requested_users(self): + user = create_user("recipient@example.com") + other_user = create_user("other@example.com") + schema = create_mailing_schema( + schema={ + "title": {"title": "Title"}, + "text": {"title": "Text"}, + }, + template="

{{ title }}

{{ text }}

", + ) + + mail_data = prepare_mail_data( + EmailDataToPrepare( + users_ids=[user.id], + subject="Subject", + schema_id=schema.id, + context_data={ + "title": "Custom title", + "text": "Custom text", + "button_link": "https://example.com", + "button_text": "Open", + }, + ) + ) + + self.assertEqual(list(mail_data["users"]), [user]) + self.assertNotIn(other_user, list(mail_data["users"])) + self.assertEqual(mail_data["subject"], "Subject") + self.assertEqual(mail_data["template_string"], schema.template) + self.assertEqual( + mail_data["template_context"], + {"title": "Custom title", "text": "Custom text"}, + ) + + +class MailingSendUtilsTests(TestCase): + def test_create_message_groups_uses_configured_batch_size(self): + messages = list(range(205)) + + groups = create_message_groups(messages) + + self.assertEqual([len(group) for group in groups], [100, 100, 5]) + + @patch("mailing.utils.send_group_messages") + def test_send_mass_mail_renders_template_for_each_user(self, send_group_messages): + send_group_messages.side_effect = lambda messages: len(messages) + first_user = create_user("first@example.com") + second_user = create_user("second@example.com") + + sent_count = send_mass_mail( + [first_user, second_user], + "Subject", + "Hello {{ user.email }}: {{ title }}", + {"title": "Reminder"}, + ) + + self.assertEqual(sent_count, 2) + send_group_messages.assert_called_once() + messages = send_group_messages.call_args.args[0] + self.assertEqual(messages[0].to, [first_user.email]) + self.assertEqual(messages[0].subject, "Subject") + self.assertIn("Hello first@example.com: Reminder", messages[0].body) + self.assertEqual(messages[1].to, [second_user.email]) + self.assertIn("Hello second@example.com: Reminder", messages[1].body) + + @patch("mailing.utils.send_group_messages") + @patch("mailing.utils.get_template") + def test_send_mass_mail_from_template_calls_status_callback( + self, + get_template, + send_group_messages, + ): + template = Mock() + template.render.side_effect = lambda context: f"Hello {context['user'].email}" + get_template.return_value = template + send_group_messages.side_effect = lambda messages: len(messages) + user = create_user("template-recipient@example.com") + handled_user_ids = [] + + sent_count = send_mass_mail_from_template( + [user], + "Subject", + "email/template.html", + status_callback=lambda handled_user, msg: handled_user_ids.append( + handled_user.id + ), + ) + + self.assertEqual(sent_count, 1) + get_template.assert_called_once_with("email/template.html") + self.assertEqual(handled_user_ids, [user.id]) + message = send_group_messages.call_args.args[0][0] + self.assertEqual(message.to, [user.email]) + self.assertIn("Hello template-recipient@example.com", message.body) From cada2ca4da88f23a9969d4e4da6d378adc2354ef Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 10 Jun 2026 10:48:32 +0500 Subject: [PATCH 31/32] =?UTF-8?q?=D0=90=D0=BA=D1=82=D1=83=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=20Chats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/chats.md | 390 ++++++++++++++++++++++++++++++------------ 1 file changed, 278 insertions(+), 112 deletions(-) diff --git a/docs/modules/chats.md b/docs/modules/chats.md index acd41526..4918c2b6 100644 --- a/docs/modules/chats.md +++ b/docs/modules/chats.md @@ -1,165 +1,331 @@ # Chats -# Документация по вебсокетам чатов -## Общая инфа -URL для всего вебсокет-релейтед - `/ws/` +## Назначение -В данный момент есть только 1 Consumer (т.е. View, но для вебсокетов). Это ChatConsumer, живет на `/ws/chat/`. +Модуль `chats` отвечает за личные чаты, чаты проектов, историю сообщений, +прикрепленные к сообщениям файлы, признаки прочтения и WebSocket-события +онлайна и сообщений. -`/ws/chat/` +Модуль состоит из двух частей: -### Подключение -Чтобы законнектиться, укажите в хедерах авторизацию по Bearer токену (как и для всех других запросов в REST API). +- REST API для списков чатов, истории сообщений, файлов и проверки непрочитанных + сообщений; +- WebSocket `/ws/chat/` для realtime-событий: создание, чтение, редактирование, + удаление сообщений, typing и online/offline. -### Events -Есть два типа ивентов, которые можно кидать - general events и chat-related events. Первые состоят только из user_online и user_offline, вторые содержат все остальное: новое сообщение, печатание, чтение и удаление (пока без редактирования) +## Статус модуля -Структура любого Event, который должен кидаться на вебсокет выглядит так: -```py -class Event: - type: EventType - content: dict -``` -И соответственно EventType вот такой: -```py -# эти строки указывать в {"type": event_type} - -class EventType(str, Enum): - # CHAT RELATED EVENTS - NEW_MESSAGE = "new_message" - DELETE_MESSAGE = "delete_message" - READ_MESSAGE = "message_read" - TYPING = "user_typing" - EDIT_MESSAGE = "edit_message" - - # GENERAL EVENTS - SET_ONLINE = "set_online" - SET_OFFLINE = "set_offline" -``` -Пример того, как выглядит Event на новое сообщение -```json -{ - "type": "new_message", - "content": { - "chat_type": "direct", - "chat_id": "12_23", - "message": "hello world", - "reply_to": 54, - "is_edited": false - } -} -``` +Модуль рабочий и подключен в публичный API через `/chats/`, а WebSocket route +подключен через ASGI routing на `/ws/chat/`. -## Методы e.g. Ивенты +Критичные WebSocket-flow покрыты тестами для личных и проектных чатов. При этом +модуль остается технически сложным: REST API и WebSocket-логика частично +дублируют ответственность, а часть старых views технически допускает `POST`, но +основной рабочий сценарий создания сообщений идет через WebSocket. -### SET_ONLINE/SET_OFFLINE -Без параметров. +## Основные возможности -### NEW_MESSAGE -- `chat_type: str`\ -`"direct"` или `"project"`, зависит от типа чата -- `chat_id: int/str`\ -Если тип `"project"`, то тип будет `int` и это айди проекта, которому принадлежит чат. Если тип `"direct"`, то это `str`. Выглядит как `{user1_id}_{user2_id}`, **где первое число всегда меньше второго**. -- `message: str` текст сообщения -- `reply_to: Optional[int]` айди сообщения, на которое кидается ответ. Если его нет, то обязательно кидать `None` +- список личных чатов пользователя; +- открытие личного чата по паре пользователей; +- список проектных чатов пользователя; +- карточка проектного чата; +- история сообщений личного или проектного чата; +- список файлов из сообщений чата; +- проверка наличия непрочитанных сообщений; +- realtime-создание сообщений; +- realtime-редактирование и удаление сообщений; +- отметка сообщения как прочитанного; +- typing-события; +- online/offline-события пользователей. -### EDIT_MESSAGE -- `chat_type: str` -- `chat_id` см выше -- `message_id: int` айди сообщение, которое прочитали -- `message: str` текст сообщения +## Архитектура -### TYPING -- `chat_type` см выше -- `chat_id` см выше +- `chats/models.py` - модели чатов, сообщений и связи файла с сообщением. +- `chats/views.py` - REST endpoints списков, detail, истории, файлов и + непрочитанных сообщений. +- `chats/serializers.py` - response serializers чатов и сообщений. +- `chats/permissions.py` - проверки доступа к личным и проектным чатам. +- `chats/consumers/chat.py` - основной WebSocket consumer. +- `chats/consumers/event_types/` - обработчики событий личных и проектных + чатов. +- `chats/utils.py` - async ORM wrappers, создание сообщений, валидация текста, + связь файлов и сообщений. +- `chats/routing.py` - WebSocket route `/ws/chat/`. +- `chats/pagination.py` - limit/offset pagination истории сообщений. +- `chats/tests/` - текущие тесты WebSocket-flow и permissions. -### READ_MESSAGE -- `chat_type` см выше -- `chat_id` см выше -- `message_id: int` айди сообщение, которое прочитали +## Ключевые сущности -#### General events +- `DirectChat` - личный чат двух пользователей. `id` хранится строкой + `_`. +- `ProjectChat` - чат проекта. `id` совпадает с `project.id`. +- `DirectChatMessage` - сообщение в личном чате. +- `ProjectChatMessage` - сообщение в проектном чате. +- `FileToMessage` - связь `UserFile` с личным или проектным сообщением. + +Общие поля сообщений: + +- `text`; +- `is_read`; +- `is_deleted`; +- `is_edited`; +- `created_at`; +- `reply_to`. + +## REST API + +- `GET /chats/directs/` - список личных чатов текущего пользователя. +- `GET /chats/directs//` - detail личного чата. Если чат между двумя + существующими пользователями еще не создан, он создается при открытии. +- `GET /chats/directs//messages/` - история сообщений личного чата. +- `GET /chats/directs//files/` - файлы из сообщений личного чата. +- `GET /chats/projects/` - список проектных чатов пользователя. +- `GET /chats/projects//` - detail проектного чата. +- `GET /chats/projects//messages/` - история сообщений проектного чата. +- `GET /chats/projects//files/` - файлы из сообщений проектного чата. +- `GET /chats/has-unreads/` - признак наличия непрочитанных сообщений. + +История сообщений использует `MessageListPagination`: + +- `limit`, по умолчанию `20`; +- `offset`. + +## WebSocket + +WebSocket endpoint: + +```text +/ws/chat/ +``` -- EventType.SET_ONLINE -- EventType.SET_OFFLINE +Аутентификация выполняется через `TokenAuthMiddleware`. Клиент должен передать +Bearer token так же, как для REST API. -Структура этих event'ов одинаковая. +Формат входящего события: ```json { - "type": "set_offline", - "content": { - - } + "type": "new_message", + "content": {} } ``` -#### Chat-related events +Типы чатов: + +- `direct` - личный чат; +- `project` - чат проекта. + +### Event Types -##### EventType.NEW_MESSAGE +- `new_message` - создание сообщения. +- `message_read` - отметка сообщения как прочитанного. +- `delete_message` - soft-delete сообщения. +- `edit_message` - редактирование сообщения. +- `user_typing` - typing-событие. +- `set_online` - пользователь онлайн. +- `set_offline` - пользователь офлайн. + +### New Message ```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", + "chat_id": "1_2", + "text": "hello world", + "reply_to": null, + "file_urls": [] + } } ``` -![New message event](../img/event_new_message.png "New message event") +Для `project` в `chat_id` передается id проекта/проектного чата: -##### EventType.TYPING +```json +{ + "type": "new_message", + "content": { + "chat_type": "project", + "chat_id": 10, + "text": "hello project", + "reply_to": null, + "file_urls": [] + } +} +``` + +### Read Message ```json { - "type": "typing", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - } + "type": "message_read", + "content": { + "chat_type": "direct", + "chat_id": "1_2", + "message_id": 100 + } } ``` -##### EventType.READ_MESSAGE +### Edit Message ```json { - "type": "typing", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - "message_id": {{number}} - } + "type": "edit_message", + "content": { + "chat_type": "project", + "chat_id": 10, + "message_id": 100, + "text": "updated text" + } } ``` -##### EventType.DELETE_MESSAGE +### Delete Message ```json { - "type": "typing", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - "message_id": {{number}} - } + "type": "delete_message", + "content": { + "chat_type": "direct", + "chat_id": "1_2", + "message_id": 100 + } } ``` -##### EventType.EDIT_MESSAGE +### Typing ```json { - "type": "edit_message", - "content": { - "chat_type": {{"direct" | "project"}}, - "chat_id": {{"id1"_"id2"}}, // например: 1_2 - "message_id": {{number}}, - "message": {{string}} - } + "type": "user_typing", + "content": { + "chat_type": "direct", + "chat_id": "1_2" + } } ``` + +## Основные сценарии + +### 1. Пользователь открывает личный чат + +Фронт обращается к `GET /chats/directs//`, где `` имеет формат +`_`. + +Backend: + +- проверяет, что текущий пользователь входит в пару id; +- проверяет существование обоих пользователей; +- создает `DirectChat`, если его еще нет; +- возвращает данные чата и opponent. + +### 2. Пользователь отправляет личное сообщение + +Клиент отправляет WebSocket event `new_message` с `chat_type = "direct"`. + +Backend: + +- нормализует id личного чата; +- создает чат, если его нет; +- создает `DirectChatMessage`; +- связывает переданные `file_urls` с сообщением через `FileToMessage`; +- отправляет событие автору и второму пользователю, если второй пользователь + сейчас подключен. + +### 3. Пользователь отправляет сообщение в проектный чат + +Клиент отправляет WebSocket event `new_message` с `chat_type = "project"`. + +Backend: + +- находит `ProjectChat`; +- проверяет, что пользователь является лидером проекта или collaborator; +- создает `ProjectChatMessage`; +- связывает файлы; +- отправляет событие в группу проектного чата. + +### 4. Пользователь читает сообщение + +`message_read` переводит `is_read=True`. + +Для личного чата прочитать можно только сообщение второго пользователя в своем +чате. Для проектного чата пользователь должен быть участником проекта. + +### 5. Пользователь редактирует или удаляет сообщение + +`edit_message` и `delete_message` доступны только автору сообщения. + +Удаление является soft-delete: сообщение получает `is_deleted=True`, история +REST API фильтрует такие сообщения. + +### 6. Пользователь подключается к WebSocket + +При подключении: + +- канал пользователя сохраняется в cache; +- пользователь добавляется в общий список online users; +- отправляется `set_online`; +- пользователь подписывается на general events; +- вне тестов пользователь также подписывается на группы своих проектных чатов. + +При отключении пользователь удаляется из online cache и отправляется +`set_offline`. + +## Связи с другими модулями + +- `users` - участники личных чатов, авторы сообщений и источник списка + проектных чатов через `CustomUser.get_project_chats()`. +- `projects` - проектный чат создается для проекта; доступ определяется через + лидера и collaborators. +- `files` - файлы прикрепляются к сообщениям через `UserFile` и `FileToMessage`. +- `metrics` - метрики онлайна читают cache-ключи, которые обновляет + `ChatConsumer`. +- `core` - constants/cache helpers для online users. + +## Ограничения и риски + +- `DirectChatMessageList` и `ProjectChatMessageList` технически наследуются от + `ListCreateAPIView`, но основной рабочий сценарий создания сообщений сейчас + WebSocket, а не REST `POST`. +- `DirectChatDetail` создает чат при `GET`, то есть чтение имеет side effect. +- `ProjectChatMessageList.get_queryset()` для несуществующего проектного чата + возвращает пустой список, а не 404. +- `DirectChatList.get()` молча пропускает чат, если у него некорректный состав + пользователей. +- `FileToMessage` допускает одновременную пустоту или неоднозначность + `direct_message` / `project_message` на уровне модели; это держится на + вызывающем коде. +- `match_files_and_messages()` прикрепляет `UserFile` по id без явной проверки, + что файл принадлежит текущему пользователю. +- `get_all_files()` собирает файлы Python-циклом по сообщениям и может быть + дорогим на больших историях. +- `DirectChat.get_avatar()` ожидает второго пользователя и может сломаться на + некорректном личном чате. +- `NotificationConsumer` существует как заготовка и не реализован. +- В `chats/managers.py` остался закомментированный legacy-код. + +## Тесты + +Текущие тесты лежат в `chats/tests/`. + +Они проверяют: + +- подключение к `ChatConsumer`; +- создание личных сообщений; +- запрет отправки в чужой личный чат; +- чтение личного сообщения вторым пользователем; +- запрет чтения своего сообщения и сообщения из чужого чата; +- редактирование своего личного сообщения; +- запрет редактирования чужого личного сообщения; +- удаление своего личного сообщения; +- запрет удаления чужого личного сообщения; +- создание сообщения в проектном чате лидером и collaborator; +- запрет сообщения в чужой проектный чат; +- чтение сообщения в проектном чате участником; +- запрет чтения сообщения вне проекта; +- редактирование и удаление своего проектного сообщения; +- запрет редактирования и удаления чужого проектного сообщения; +- доступ к detail проектного чата для лидера и collaborator; +- запрет detail проектного чата для outsider. From 5b5fc21f80ce6c9092139a8233ec28868abca3f6 Mon Sep 17 00:00:00 2001 From: Toksi Date: Wed, 10 Jun 2026 10:55:33 +0500 Subject: [PATCH 32/32] =?UTF-8?q?=D0=90=D0=BA=D1=82=D1=83=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=20Core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/core.md | 232 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 231 insertions(+), 1 deletion(-) diff --git a/docs/modules/core.md b/docs/modules/core.md index 9fbc65bf..13636804 100644 --- a/docs/modules/core.md +++ b/docs/modules/core.md @@ -1,3 +1,233 @@ # Core -TODO +## Назначение + +Модуль `core` содержит общие сущности и инфраструктурные helper'ы, которые +переиспользуются другими доменными модулями Procollab. + +В модуле находятся: + +- generic-модели лайков, просмотров и ссылок; +- справочники навыков и специализаций; +- generic-связи навыков и специализаций с объектами; +- REST endpoints справочника навыков; +- общие serializers, permissions и pagination; +- helpers для Excel-выгрузок; +- cache-ключи онлайна пользователей; +- WebSocket JWT middleware; +- logging middleware. + +## Статус модуля + +`core` подключен в публичный API через `/core/`, но публичная API-поверхность +сейчас ограничена endpoints навыков. + +Модуль является shared-слоем: изменения в нем могут затронуть `users`, +`projects`, `news`, `feed`, `vacancy`, `partner_programs`, `courses`, +`project_rates`, `metrics` и `chats`. + +Собственных тестов у `core` сейчас нет. Часть поведения косвенно покрывается +тестами зависимых модулей. + +## Основные возможности + +- хранение generic-лайков через `Like`; +- хранение generic-просмотров через `View`; +- хранение generic-ссылок через `Link`; +- справочник навыков `SkillCategory` / `Skill`; +- generic-привязка навыков через `SkillToObject`; +- справочник специализаций `SpecializationCategory` / `Specialization`; +- generic-привязка специализаций через `SpecializationToObject`; +- получение навыков nested-списком по категориям; +- получение навыков плоским paginated-списком с фильтром по названию; +- подготовка XLSX-файлов в памяти; +- безопасная подготовка имени файла и значений Excel-ячеек; +- построение download-response для XLSX; +- формирование ключей online-cache; +- JWT-аутентификация WebSocket через subprotocol; +- перехват стандартного logging в loguru. + +## Архитектура + +- `core/models.py` - generic-модели, навыки и специализации. +- `core/views.py` - API справочника навыков. +- `core/serializers.py` - serializers навыков и общие request serializers. +- `core/services.py` - лайки, просмотры, ссылки и Base64 image encoder. +- `core/utils.py` - email helper, online-cache keys и Excel helpers. +- `core/permissions.py` - общие permissions. +- `core/pagination.py` - общий limit/offset pagination. +- `core/filters.py` - фильтр навыков. +- `core/fields.py` - кастомное поле списка для comma-separated значений. +- `core/auth/middleware.py` - WebSocket JWT auth middleware. +- `core/log/` - интеграция стандартного logging с loguru. +- `core/admin.py` - Django admin для core-сущностей. + +## Ключевые сущности + +- `Like` - generic-лайк пользователя к объекту через `ContentType`. +- `View` - generic-просмотр пользователя к объекту через `ContentType`. +- `Link` - generic-ссылка, привязанная к объекту через `ContentType`. +- `SkillCategory` - категория навыка. +- `Skill` - навык внутри категории. +- `SkillToObject` - generic-связь навыка с пользователем, вакансией, проектом + или другим объектом. +- `SpecializationCategory` - категория специализации. +- `Specialization` - специализация внутри категории. +- `SpecializationToObject` - generic-связь специализации с объектом. + +## API + +- `GET /core/skills/nested/` - категории навыков со вложенным списком навыков. +- `GET /core/skills/inline/` - плоский список навыков с pagination. + +Фильтр для `/core/skills/inline/`: + +- `name__icontains` - поиск навыка по части названия. + +Pagination: + +- `limit`, по умолчанию `10`; +- `offset`. + +Справочник специализаций физически хранится в `core`, но endpoints находятся в +модуле `users`: + +- `GET /auth/users/specializations/nested/`; +- `GET /auth/users/specializations/inline/`. + +## Основные сценарии + +### 1. Фронт получает справочник навыков + +Для отображения навыков по категориям используется: + +```text +GET /core/skills/nested/ +``` + +Для поиска и autocomplete используется: + +```text +GET /core/skills/inline/?name__icontains=python +``` + +### 2. Модуль привязывает навыки к объекту + +Доменные модули создают `SkillToObject` через `ContentType`. + +Например: + +- `users` хранит навыки пользователя; +- `vacancy` хранит требуемые навыки вакансии; +- serializers используют `SkillToObjectSerializer` для единого response + формата навыка. + +### 3. Модуль фиксирует лайк или просмотр + +`core.services` предоставляет функции: + +- `set_like(obj, user, is_liked)`; +- `add_like(obj, user)`; +- `remove_like(obj, user)`; +- `is_fan(obj, user)`; +- `get_likes_count(obj)`; +- `set_viewed(obj, user, is_viewed)`; +- `add_view(obj, user)`; +- `remove_view(obj, user)`; +- `is_viewer(obj, user)`; +- `get_views_count(obj)`. + +Эти функции используются в `news`, `feed`, `partner_programs`, +`project_rates` и других местах, где нужен generic-счетчик. + +Важно: не все лайки в проекте уже переведены на generic-модель `core.Like`. +Например, у проектов и мероприятий еще есть отдельные legacy-модели лайков. + +### 4. Модуль формирует XLSX-выгрузку + +Для выгрузок используются: + +- `XlsxFileToExport`; +- `sanitize_excel_value`; +- `build_xlsx_download_response`. + +Эти helpers применяются в `partner_programs`, `project_rates`, `courses`, +`users` и `vacancy`. + +### 5. WebSocket подключение проходит JWT-аутентификацию + +`TokenAuthMiddleware` подключен в `procollab/asgi.py`. + +Он ожидает WebSocket subprotocols в формате: + +```text +["Bearer", ""] +``` + +После проверки JWT middleware записывает пользователя в `scope["user"]`. +Этим пользуется `chats.ChatConsumer`. + +### 6. Чаты обновляют online-cache + +`core.utils` содержит функции: + +- `get_user_online_cache_key(user)`; +- `get_users_online_cache_key()`. + +`chats` пишет в эти ключи при подключении и отключении пользователя, а +`metrics` читает aggregate-ключ для отображения количества пользователей онлайн. + +## Связи с другими модулями + +- `users` - навыки, специализации, online-флаги, Excel-выгрузки и permissions. +- `vacancy` - required skills через `SkillToObject`, admin inline и выгрузки. +- `projects` - общие serializers/permissions, счетчики просмотров, online + данные пользователей. +- `news` - generic likes/views. +- `feed` - generic likes/views для записей ленты. +- `partner_programs` - generic likes/views и Excel-выгрузки. +- `project_rates` - счетчики просмотров проектов и выгрузки. +- `courses` - Excel-выгрузка результатов. +- `chats` - WebSocket auth и online-cache keys. +- `metrics` - чтение online-cache. +- `industries` и `events` - переиспользуют общие permissions. + +## Ограничения и риски + +- У `core` нет собственных тестов; shared-поведение проверяется в основном + косвенно через другие модули. +- `remove_link()` в `core.services` фильтрует `Like`, а не `Link`; это выглядит + как баг. +- `get_views_count()` кеширует значение, но `add_view()` / `remove_view()` не + инвалидируют кеш. +- `get_likes_count()` не использует кеш, хотя `LIKES_CACHING_TIMEOUT` объявлен. +- `Skill`, `SkillCategory`, `Specialization` и `SpecializationCategory` не имеют + уникальности по `name`. +- `SkillToObject` и `SpecializationToObject` не ограничивают дубли на уровне + модели. +- `Base64ImageEncoder.get_encoded_base64_from_url()` использует `urlopen` без + timeout. +- `TokenAuthentication.authenticate()` не обрабатывает отсутствие пользователя + после декодирования JWT. +- `CustomLoguruMiddleware` пишет логи в директорию `log/` внутри `BASE_DIR`; + окружение должно гарантировать доступность этой директории. +- `CustomListField` преобразует список в строку через запятую и обратно; формат + подходит не для всех типов значений. + +## Тесты + +Собственных тестов у модуля сейчас нет: + +```text +DEBUG=True .venv/bin/python manage.py test core +``` + +Текущий запуск находит `0` тестов. + +Поведение `core` частично покрывается тестами зависимых модулей: + +- `news` и `feed` проверяют generic likes/views; +- `vacancy` и `users` проверяют работу навыков; +- `metrics` проверяет online-cache keys; +- `partner_programs`, `project_rates`, `courses` проверяют Excel-выгрузки через + общие helpers.