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/docs/modules/partner-programs.md b/docs/modules/partner-programs.md index 575b483b..d31bb234 100644 --- a/docs/modules/partner-programs.md +++ b/docs/modules/partner-programs.md @@ -1,3 +1,212 @@ # 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/` - сервисы регистрации в программе, подачи + проектов, фильтрации проектов, публикации проектов и подготовки + 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 программы с курсами для участника и не-участника; +- подачу проекта участником программы или менеджером программы; +- запрет повторной подачи проекта тем же лидером; +- запрет подачи проекта не-участником, после дедлайна, с дублями полей, + незаполненными обязательными полями и полями другой программы; +- обновление дополнительных полей проекта лидером; +- запрет обновления полей не-лидером и после сдачи конкурсного проекта; +- сдачу проекта на проверку; +- запрет сдачи проекта не-лидером, после дедлайна и для неконкурсной программы; +- список фильтров программы; +- фильтрацию проектов программы по дополнительным полям; +- список проектов программы для менеджера; +- Excel-выгрузку проектов программы, включая режим `only_submitted`; +- запрет выгрузки проектов пользователем без прав менеджера; +- Excel-выгрузку оценок проектов программы; +- подготовку данных выгрузки оценок, когда критерии есть, но оценок еще нет; +- базовые permissions для менеджера программы, staff-пользователя, + постороннего пользователя, anonymous и лидера проекта; +- публикацию проектов после завершения программы. 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) 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 83% rename from partner_programs/services.py rename to partner_programs/services/exports.py index d758de6b..e2c153ef 100644 --- a/partner_programs/services.py +++ b/partner_programs/services/exports.py @@ -1,9 +1,13 @@ +import io import logging from collections import OrderedDict +from dataclasses import dataclass from django.db.models import Prefetch from django.utils import timezone +from openpyxl import Workbook +from core.utils import XlsxFileToExport, sanitize_excel_value from partner_programs.models import ( PartnerProgram, PartnerProgramField, @@ -12,36 +16,15 @@ PartnerProgramUserProfile, ) from project_rates.models import Criteria, ProjectScore -from projects.models import Project +from projects.models import Collaborator logger = logging.getLogger() -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) +@dataclass(frozen=True) +class ProgramExportFile: + binary_data: bytes + base_name: str class ProjectScoreDataPreparer: @@ -286,6 +269,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/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.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_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_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_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 new file mode 100644 index 00000000..9a07bd41 --- /dev/null +++ b/partner_programs/tests/test_program_api.py @@ -0,0 +1,244 @@ +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.services.registration.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.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) + + 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.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 + ) + 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.services.registration.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() + + @patch("partner_programs.services.registration.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.registration.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.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/", + { + "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.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/", + { + "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.registration.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_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..8416e988 --- /dev/null +++ b/partner_programs/tests/test_program_filters.py @@ -0,0 +1,245 @@ +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.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) + + 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): + 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) + + +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_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..6dc3563c --- /dev/null +++ b/partner_programs/tests/test_project_apply.py @@ -0,0 +1,212 @@ +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_program_project, + create_project, + 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_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") + 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) 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, + )