diff --git a/courses/tests/helpers.py b/courses/tests/helpers.py index b5d428b7..0cbf0f5a 100644 --- a/courses/tests/helpers.py +++ b/courses/tests/helpers.py @@ -26,6 +26,9 @@ from users.models import CustomUser +DEFAULT_MODULE_START_DATE = date(2026, 1, 1) + + @dataclass(frozen=True) class CourseTestContext: user: CustomUser @@ -110,7 +113,7 @@ def create_module( return CourseModule.objects.create( course=course, title=title, - start_date=start_date_value or date.today(), + start_date=start_date_value or DEFAULT_MODULE_START_DATE, status=status, order=order, ) diff --git a/docs/modules/projects.md b/docs/modules/projects.md index ba1203cb..e86e8e58 100644 --- a/docs/modules/projects.md +++ b/docs/modules/projects.md @@ -1,3 +1,273 @@ # Projects -TODO +## Назначение + +Projects отвечают за проектную часть Procollab: создание и редактирование +проектов, публичность, участие команды, связь с партнерскими программами, +целями, ресурсами, компаниями, вакансиями, подписками и пользовательскими +действиями вокруг проекта. + +## Статус модуля + +Модуль рабочий, но находится в состоянии технического долга. Основная логика +исторически сосредоточена в крупных файлах `projects/views.py`, +`projects/serializers.py` и `projects/models.py`. + +Перед активным рефакторингом модуль требует: + +- поддержки regression-тестов при дальнейшем рефакторинге; +- уточнения актуальных и legacy-сценариев; +- разделения бизнес-логики из views/serializers на более явные service/query + слои; +- проверки endpoints участников проекта и обновления проекта. + +## Основные возможности + +- создание и редактирование проекта; +- просмотр публичных проектов; +- скрытие проекта из общего каталога через `is_public`; +- работа с черновиками через `draft`; +- привязка проекта к партнерской программе; +- дублирование проекта в партнерскую программу; +- управление участниками проекта; +- смена лидера проекта; +- подписка и отписка от проекта; +- лайки и просмотры проекта; +- цели проекта; +- ресурсы проекта; +- компании-партнеры проекта; +- вакансии и отклики по проекту; +- отображение проекта в чате, новостях и ленте через связанные модули. + +## Архитектура + +- `projects/models.py` - модели проекта, участников, целей, компаний, + ресурсов, ссылок, достижений и остаточной старой модели `ProjectNews`. +- `projects/views.py` - HTTP endpoints и значительная часть orchestration + logic. +- `projects/serializers.py` - request/response contracts, часть validation и + часть бизнес-логики создания/обновления связей. +- `projects/permissions.py` - правила видимости проекта и доступа к операциям. +- `projects/helpers.py` - вспомогательная бизнес-логика: рекомендации + пользователей, обновление ссылок/достижений, привязка к программе. +- `projects/managers.py` - queryset helpers для списков, деталей и счетчиков. +- `projects/signals.py` - side effects при сохранении проекта. +- `projects/admin.py` - настройка Django admin. +- `projects/tests/helpers.py` - тестовые builders/factories для проектов, + пользователей, программ, компаний и ресурсов. +- `projects/tests/test_*.py` - regression-тесты API и ключевых правил модуля. + +## Основные сущности + +- `Project` - проект. +- `Collaborator` - участник проекта. +- `ProjectLink` - ссылка проекта. +- `Achievement` - достижение проекта. +- `ProjectGoal` - цель проекта. +- `Company` - компания. +- `ProjectCompany` - связь проекта и компании. +- `Resource` - ресурс проекта. +- проектные новости - новости внутри проекта; актуальный API реализован через + `news.News` с привязкой к `Project` через `content_type/object_id`. +- `ProjectNews` - старая модель проектных новостей, оставшаяся после переноса + данных в `news.News`. +- `DefaultProjectCover` - дефолтная обложка проекта. +- `DefaultProjectAvatar` - дефолтный аватар проекта. + +## API + +- `GET /projects/` - список публичных проектов. +- `POST /projects/` - создание проекта. +- `GET /projects//` - детали проекта. +- `PATCH /projects//` - частичное обновление проекта. +- `PUT /projects//` - полное обновление проекта. +- `DELETE /projects//` - удаление проекта. +- `POST /projects//like/` - лайк проекта. +- `GET /projects/count/` - счетчики проектов. +- `GET /projects//recommended_users` - рекомендованные пользователи. +- `GET /projects//collaborators/` - список участников проекта. +- `POST /projects//collaborators/` - добавление участников проекта. +- `DELETE /projects//collaborators/` - удаление участника проекта. +- `DELETE /projects//collaborators/leave/` - выход пользователя из проекта. +- `PATCH /projects//collaborators//switch-leader/` - + смена лидера проекта. +- `GET /projects//news/` - список новостей внутри проекта. +- `POST /projects//news/` - создание новости проекта. +- `GET /projects//news//` - детальная новость проекта. +- `PATCH /projects//news//` - редактирование новости проекта. +- `DELETE /projects//news//` - удаление новости проекта. +- `POST /projects//news//set_viewed/` - просмотр новости проекта. +- `POST /projects//news//set_liked/` - лайк новости проекта. +- `POST /projects//subscribe/` - подписка на проект. +- `POST /projects//unsubscribe/` - отписка от проекта. +- `GET /projects//subscribers/` - подписчики проекта. +- `GET /projects//goals/` - цели проекта. +- `POST /projects//goals/` - массовое создание целей проекта. +- `GET /projects//goals//` - детальная цель проекта. +- `PATCH /projects//goals//` - обновление цели проекта. +- `GET /projects//resources/` - ресурсы проекта. +- `POST /projects//resources/` - создание ресурса проекта. +- `GET /projects//resources//` - детальный ресурс проекта. +- `PATCH /projects//resources//` - обновление ресурса. +- `POST /projects//companies/` - создание или привязка компании к проекту. +- `GET /projects//companies/list/` - список компаний проекта. +- `PATCH /projects//companies//` - обновление связи с компанией. +- `DELETE /projects//companies//` - удаление связи с компанией. +- `POST /projects/assign-to-program/` - дублирование проекта и привязка к + партнерской программе. +- `GET /projects//responses/` - отклики на вакансии проекта. + +## Основные сценарии + +### 1. Создание проекта + +Пользователь создает проект через `POST /projects/`. Лидер проекта +подставляется из текущего пользователя. + +После создания проекта срабатывают side effects: + +- создается `Collaborator` для лидера проекта; +- если проект опубликован, создается `ProjectChat`; +- при изменении `draft` синхронизируется активность вакансий и новости в ленте. + +Если при создании передан `partner_program_id`, проект дополнительно +привязывается к партнерской программе. + +### 2. Просмотр проекта + +Пользователь открывает список проектов или карточку проекта. В публичном +каталоге отображаются проекты с `draft = False` и `is_public = True`. + +Детальный просмотр проекта учитывает `ProjectVisibilityPermission`. +Непубличный проект доступен: + +- лидеру; +- администраторам; +- участникам команды; +- пользователям с invite; +- менеджерам и экспертам связанной партнерской программы. + +### 3. Редактирование проекта + +Редактирование проекта выполняется через `PUT` или `PATCH`. + +При обновлении отдельно обрабатываются: + +- `partner_program_id`; +- `achievements`, если этот сценарий останется актуальным; +- `links`, если этот сценарий останется актуальным. + +Сейчас часть этой логики находится во view/helper слое и требует последующего +выноса в более явный service layer. + +### 4. Участники проекта + +Проект хранит участников через модель `Collaborator`. + +Поддерживаются сценарии: + +- просмотр участников; +- добавление участников; +- удаление участника; +- выход пользователя из проекта; +- смена лидера проекта. + +Если проект привязан к партнерской программе, `Collaborator.clean()` проверяет, +что добавляемый пользователь является участником этой программы. + +### 5. Партнерская программа + +Проект может быть связан с партнерской программой через `PartnerProgramProject` +и `PartnerProgramUserProfile`. + +Привязка учитывает: + +- дедлайн подачи проектов; +- завершенность программы; +- участие пользователя в программе; +- возможность менеджера привязывать проект к программе. + +Есть отдельный сценарий дублирования проекта в программу через +`POST /projects/assign-to-program/`. + +### 6. Цели, ресурсы и компании + +Проект может содержать: + +- цели (`ProjectGoal`); +- компании-партнеры (`Company`, `ProjectCompany`); +- ресурсы (`Resource`). + +`Resource` может быть связан с компанией только если эта компания уже является +партнером проекта. + +### 7. Новости проекта + +Новости внутри проекта доступны через `/projects//news/`. +Несмотря на проектный URL, актуальная реализация использует общий модуль +`news`: запись хранится в `news.News`, а связь с проектом задается через +`content_type = Project` и `object_id = project.id`. + +Старые `ProjectNews`, `ProjectNews*Serializer` и `ProjectNews*View` остаются в +коде, но текущие routes проекта подключены к `news.views`. + +## Ограничения и правила + +- Публичный каталог показывает только `draft = False` и `is_public = True`. +- Непубличные проекты требуют проверки `ProjectVisibilityPermission`. +- Лидер проекта автоматически становится участником проекта. +- Лидер не должен удаляться из проекта как обычный участник. +- Проект, связанный с программой, ограничивает редактирование после завершения + программы. +- Компания может быть связана с проектом только один раз. +- Ресурс может ссылаться только на компанию, уже привязанную к проекту. +- Проектные новости являются живым frontend-сценарием, но текущие routes + используют общий модуль `news`, а не старую модель `ProjectNews`. + +## Тесты + +Для модуля добавлен первый слой regression-тестов и тестовых helpers. + +`projects/tests/helpers.py` содержит builders/factories для повторяемых +сущностей: + +- user; +- project; +- collaborator; +- partner program; +- company; +- project company; +- project goal; +- resource. + +Текущие regression-тесты проверяют: + +- создание проекта и side effects: лидер становится collaborator, создается чат + для опубликованного проекта; +- `PUT /projects//` по frontend-контракту: полное обновление формы проекта, + сохранение текущего лидера, привязка к программе через `partner_program_id` + для участника программы и запрет обновления посторонним пользователем; +- visibility rules для публичных и непубличных проектов; +- привязка проекта к партнерской программе; +- запрет привязки к программе без участия пользователя в программе; +- дублирование проекта в партнерскую программу с копированием данных и + участников; +- запрет дублирования проекта пользователем, который не является лидером; +- upsert компаний проекта; +- создание ресурсов проекта; +- запрет привязки ресурса к компании, которая не является партнером проекта; +- создание списка целей проекта; +- частичное обновление одной цели проекта; +- запрет создания целей пользователем, который не является лидером проекта; +- подписка, отписка и список подписчиков проекта; +- новости проекта: создание лидером, список внутри проекта, detail для + модалки, отметка просмотра, лайк/снятие лайка, редактирование и удаление + лидером, запрет создания не-лидером; +- участники проекта: просмотр команды, удаление существующего участника + лидером, выход участника из проекта, запрет выхода лидера, смена лидера на + существующего участника и отказ при смене лидера на пользователя вне команды; +- фильтры списка проектов: `industry`, `leader`, `partner_program`, + `is_company`, `collaborator__user__in`; +- signals вокруг `draft`: публикация проекта активирует вакансии, создает + feed-news для вакансий и `ProjectChat`; возврат в черновик деактивирует + вакансии и удаляет их feed-news; повторная публикация не создает дубли. diff --git a/projects/serializers.py b/projects/serializers.py index 0d1af8ac..6d9127c0 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.core.cache import cache +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from rest_framework import serializers @@ -216,14 +217,20 @@ def validate(self, attrs): def create(self, validated_data): obj = Resource(**validated_data) - obj.full_clean() + try: + obj.full_clean() + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.message_dict) obj.save() return obj def update(self, instance, validated_data): for key, value in validated_data.items(): setattr(instance, key, value) - instance.full_clean() + try: + instance.full_clean() + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.message_dict) instance.save() return instance @@ -375,6 +382,7 @@ class Meta: "short_description", "image_address", "industry", + "draft", "views_count", "is_company", "partner_program", diff --git a/projects/signals.py b/projects/signals.py index 53dee97f..09f89a5c 100644 --- a/projects/signals.py +++ b/projects/signals.py @@ -25,16 +25,13 @@ def create_project(sender, instance, created, **kwargs): @receiver(post_save, sender=Project) def update_vacancy(sender, instance, created, **kwargs): vacancies = Vacancy.objects.filter(project=instance) - old_values = vacancies.values_list("is_active", flat=True) + old_values_by_id = dict(vacancies.values_list("id", "is_active")) vacancies.update(is_active=False if instance.draft else True) - new_values = vacancies.values_list("is_active", flat=True) - vacancies_list = list(vacancies) - - for i in range(len(new_values)): - old = old_values[i] - new = new_values[i] + for vacancy in vacancies: + old = old_values_by_id[vacancy.id] + new = vacancy.is_active if old != new and new is False: - delete_news_for_model(vacancies_list[i]) + delete_news_for_model(vacancy) elif old != new and new is True: - create_news_for_model(vacancies_list[i]) + create_news_for_model(vacancy) diff --git a/projects/tests.py b/projects/tests.py deleted file mode 100644 index c046d699..00000000 --- a/projects/tests.py +++ /dev/null @@ -1,72 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIRequestFactory, force_authenticate - -from industries.models import Industry -from projects.models import Project -from projects.views import ProjectList, ProjectDetail -from tests.constants import USER_CREATE_DATA -from users.models import CustomUser -from users.views import UserList - - -class ProjectTestCase(TestCase): - def setUp(self): - self.factory = APIRequestFactory() - self.project_list_view = ProjectList.as_view() - self.user_list_view = UserList.as_view() - self.project_detail_view = ProjectDetail.as_view() - self.project_create_data = { - "name": "Test", - "description": "Test", - "industry": Industry.objects.create(name="Test").id, - } - - def test_project_creation(self): - user = self.user_create() - request = self.factory.post("projects/", self.project_create_data) - force_authenticate(request, user=user) - - response = self.project_list_view(request) - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data["name"], "Test") - self.assertEqual(response.data["leader"], user.pk) - self.assertEqual(response.data["industry"], 1) - # self.assertEqual(response.data["description"], "Test") - - def test_project_creation_with_wrong_data(self): - user = self.user_create() - request = self.factory.post( - "projects/", - { - "name": "T" * 257, - "description": "Test", - "industry": Industry.objects.create(name="Test").id, - }, - ) - force_authenticate(request, user=user) - response = self.project_list_view(request) - self.assertEqual(response.status_code, 400) - - def test_project_update(self): - user = self.user_create() - request = self.factory.post("projects/", self.project_create_data) - force_authenticate(request, user=user) - - response = self.project_list_view(request) - project_id = response.data["id"] - project = Project.objects.get(id=project_id) - - request = self.factory.patch(f"projects/{project.pk}/", {"name": "Test2"}) - force_authenticate(request, user=user) - response = self.project_detail_view(request, pk=project.pk) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["name"], "Test2") - - def user_create(self): - request = self.factory.post("auth/users/", USER_CREATE_DATA) - response = self.user_list_view(request) - user_id = response.data["id"] - user = CustomUser.objects.get(id=user_id) - user.is_active = True - user.save() - return user diff --git a/projects/tests/__init__.py b/projects/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/projects/tests/helpers.py b/projects/tests/helpers.py new file mode 100644 index 00000000..1e6dee01 --- /dev/null +++ b/projects/tests/helpers.py @@ -0,0 +1,244 @@ +from dataclasses import dataclass +from datetime import timedelta +from uuid import uuid4 + +from django.utils import timezone + +from industries.models import Industry +from partner_programs.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from projects.models import ( + Collaborator, + Company, + Project, + ProjectCompany, + ProjectGoal, + Resource, +) +from users.models import CustomUser +from vacancy.models import Vacancy + + +@dataclass(frozen=True) +class ProjectTestContext: + user: CustomUser + project: Project + + +def unique_suffix() -> str: + return uuid4().hex[:8] + + +def unique_digits(length: int = 10) -> str: + return str(uuid4().int)[:length] + + +def create_user(*, prefix: str = "projects-test") -> CustomUser: + suffix = unique_suffix() + return CustomUser.objects.create_user( + email=f"{prefix}-{suffix}@example.com", + password="testpass123", + first_name="Test", + last_name="User", + birthday="2000-01-01", + is_active=True, + ) + + +def create_staff_user(*, prefix: str = "projects-admin") -> CustomUser: + suffix = unique_suffix() + return CustomUser.objects.create_superuser( + email=f"{prefix}-{suffix}@example.com", + password="testpass123", + first_name="Admin", + last_name="User", + ) + + +def create_industry(*, name: str = "Industry") -> Industry: + return Industry.objects.create(name=f"{name} {unique_suffix()}") + + +def create_project( + *, + leader: CustomUser | None = None, + name: str = "Project", + description: str = "Project description", + draft: bool = True, + is_public: bool = True, + industry: Industry | None = None, +) -> Project: + return Project.objects.create( + leader=leader or create_user(prefix="project-leader"), + name=f"{name} {unique_suffix()}", + description=description, + draft=draft, + is_public=is_public, + industry=industry or create_industry(), + ) + + +def create_project_context( + *, + user_prefix: str = "projects-test", + project_name: str = "Project", + draft: bool = True, + is_public: bool = True, +) -> ProjectTestContext: + user = create_user(prefix=user_prefix) + project = create_project( + leader=user, + name=project_name, + draft=draft, + is_public=is_public, + ) + return ProjectTestContext(user=user, project=project) + + +def create_collaborator( + project: Project, + *, + user: CustomUser | None = None, + role: str = "Участник", + specialization: str | None = None, +) -> Collaborator: + collaborator, _ = Collaborator.objects.get_or_create( + project=project, + user=user or create_user(prefix="project-collaborator"), + defaults={ + "role": role, + "specialization": specialization, + }, + ) + return collaborator + + +def create_partner_program( + *, + name: str = "Program", + is_competitive: bool = False, + finished: bool = False, + submission_closed: bool = False, +) -> PartnerProgram: + suffix = unique_suffix() + now = timezone.now() + datetime_finished = now - timedelta(days=1) if finished else now + timedelta(days=30) + registration_ends = ( + now - timedelta(days=1) if submission_closed else now + timedelta(days=10) + ) + return PartnerProgram.objects.create( + name=f"{name} {suffix}", + tag=f"program-{suffix}", + city="Moscow", + is_competitive=is_competitive, + datetime_registration_ends=registration_ends, + datetime_started=now - timedelta(days=1), + datetime_finished=datetime_finished, + draft=False, + ) + + +def add_program_member( + program: PartnerProgram, + user: CustomUser, + *, + project: Project | None = None, +) -> PartnerProgramUserProfile: + return PartnerProgramUserProfile.objects.create( + user=user, + partner_program=program, + project=project, + partner_program_data={}, + ) + + +def link_project_to_program( + project: Project, + program: PartnerProgram, + *, + submitted: bool = False, +) -> PartnerProgramProject: + return PartnerProgramProject.objects.create( + project=project, + partner_program=program, + submitted=submitted, + ) + + +def create_company(*, name: str = "Company", inn: str | None = None) -> Company: + suffix = unique_suffix() + return Company.objects.create( + name=f"{name} {suffix}", + inn=inn or unique_digits(10), + ) + + +def create_project_company( + project: Project, + company: Company | None = None, + *, + contribution: str = "Contribution", + decision_maker: CustomUser | None = None, +) -> ProjectCompany: + return ProjectCompany.objects.create( + project=project, + company=company or create_company(), + contribution=contribution, + decision_maker=decision_maker, + ) + + +def create_resource( + project: Project, + *, + type: str = Resource.ResourceType.INFORMATION, + description: str = "Resource description", + partner_company: Company | None = None, +) -> Resource: + if partner_company: + ProjectCompany.objects.get_or_create( + project=project, + company=partner_company, + ) + + resource = Resource( + project=project, + type=type, + description=description, + partner_company=partner_company, + ) + resource.full_clean() + resource.save() + return resource + + +def create_project_goal( + project: Project, + *, + responsible: CustomUser | None = None, + title: str = "Project goal", + is_done: bool = False, +) -> ProjectGoal: + return ProjectGoal.objects.create( + project=project, + title=f"{title} {unique_suffix()}", + responsible=responsible or project.leader, + is_done=is_done, + ) + + +def create_vacancy( + project: Project, + *, + role: str = "Backend developer", + is_active: bool = True, +) -> Vacancy: + return Vacancy.objects.create( + project=project, + role=f"{role} {unique_suffix()}", + description="Vacancy description", + is_active=is_active, + ) diff --git a/projects/tests/test_project_access.py b/projects/tests/test_project_access.py new file mode 100644 index 00000000..db8c4106 --- /dev/null +++ b/projects/tests/test_project_access.py @@ -0,0 +1,42 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from .helpers import ( + create_collaborator, + create_project, + create_project_context, + create_user, +) + + +class ProjectVisibilityRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_private_project_is_hidden_from_unrelated_user(self): + project = create_project(draft=False, is_public=False) + unrelated_user = create_user(prefix="unrelated-project-user") + self.client.force_authenticate(unrelated_user) + + response = self.client.get(f"/projects/{project.id}/") + + self.assertEqual(response.status_code, 403) + + def test_private_project_is_available_to_leader(self): + context = create_project_context(draft=False, is_public=False) + self.client.force_authenticate(context.user) + + response = self.client.get(f"/projects/{context.project.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], context.project.id) + + def test_private_project_is_available_to_collaborator(self): + project = create_project(draft=False, is_public=False) + collaborator = create_collaborator(project).user + self.client.force_authenticate(collaborator) + + response = self.client.get(f"/projects/{project.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], project.id) diff --git a/projects/tests/test_project_collaborators.py b/projects/tests/test_project_collaborators.py new file mode 100644 index 00000000..de82cefa --- /dev/null +++ b/projects/tests/test_project_collaborators.py @@ -0,0 +1,110 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from projects.models import Collaborator + +from .helpers import ( + create_collaborator, + create_project_context, + create_user, +) + + +class ProjectCollaboratorRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + context = create_project_context( + user_prefix="project-collaborator-leader", + draft=True, + ) + self.leader = context.user + self.project = context.project + self.client.force_authenticate(self.leader) + + def test_get_collaborators_returns_project_team(self): + teammate = create_user(prefix="project-collaborator-teammate") + create_collaborator( + self.project, + user=teammate, + role="Engineer", + specialization="Backend", + ) + + response = self.client.get(f"/projects/{self.project.id}/collaborators/") + + self.assertEqual(response.status_code, 200) + collaborator_ids = [ + collaborator["user_id"] for collaborator in response.data["collaborators"] + ] + self.assertIn(self.leader.id, collaborator_ids) + self.assertIn(teammate.id, collaborator_ids) + + def test_leader_can_delete_existing_collaborator(self): + teammate = create_user(prefix="project-collaborator-delete") + create_collaborator(self.project, user=teammate) + + response = self.client.delete( + f"/projects/{self.project.id}/collaborators/?id={teammate.id}", + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse( + Collaborator.objects.filter(project=self.project, user=teammate).exists() + ) + + def test_collaborator_can_leave_project(self): + teammate = create_user(prefix="project-collaborator-leave") + create_collaborator(self.project, user=teammate) + self.client.force_authenticate(teammate) + + response = self.client.delete( + f"/projects/{self.project.id}/collaborators/leave/" + ) + + self.assertEqual(response.status_code, 204) + self.assertFalse( + Collaborator.objects.filter(project=self.project, user=teammate).exists() + ) + + def test_leader_cannot_leave_project_as_regular_collaborator(self): + response = self.client.delete( + f"/projects/{self.project.id}/collaborators/leave/" + ) + + self.assertEqual(response.status_code, 422) + self.assertTrue( + Collaborator.objects.filter(project=self.project, user=self.leader).exists() + ) + + def test_leader_can_switch_project_leader_to_existing_collaborator(self): + teammate = create_user(prefix="project-collaborator-new-leader") + create_collaborator( + self.project, + user=teammate, + role="Engineer", + ) + + response = self.client.patch( + ( + f"/projects/{self.project.id}/collaborators/" + f"{teammate.id}/switch-leader/" + ) + ) + + self.assertEqual(response.status_code, 204) + self.project.refresh_from_db() + self.assertEqual(self.project.leader, teammate) + + def test_switch_leader_rejects_user_who_is_not_project_collaborator(self): + outsider = create_user(prefix="project-collaborator-outsider") + + response = self.client.patch( + ( + f"/projects/{self.project.id}/collaborators/" + f"{outsider.id}/switch-leader/" + ) + ) + + self.assertEqual(response.status_code, 422) + self.project.refresh_from_db() + self.assertEqual(self.project.leader, self.leader) diff --git a/projects/tests/test_project_companies_resources.py b/projects/tests/test_project_companies_resources.py new file mode 100644 index 00000000..dc461c39 --- /dev/null +++ b/projects/tests/test_project_companies_resources.py @@ -0,0 +1,139 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from projects.models import Company, ProjectCompany, Resource + +from .helpers import ( + create_company, + create_project_context, + create_resource, +) + + +class ProjectCompanyRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + context = create_project_context( + user_prefix="project-company-user", + draft=True, + ) + self.user = context.user + self.project = context.project + self.client.force_authenticate(self.user) + + def test_company_upsert_creates_company_and_project_link(self): + response = self.client.post( + f"/projects/{self.project.id}/companies/", + { + "name": "Industrial partner", + "inn": "7701234567", + "contribution": "Equipment", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + company = Company.objects.get(inn="7701234567") + link = ProjectCompany.objects.get(project=self.project, company=company) + self.assertEqual(link.contribution, "Equipment") + self.assertEqual(response.data["company"]["id"], company.id) + + def test_company_upsert_updates_existing_project_link_without_duplicate(self): + company = create_company(inn="7707654321") + ProjectCompany.objects.create( + project=self.project, + company=company, + contribution="Initial contribution", + ) + + response = self.client.post( + f"/projects/{self.project.id}/companies/", + { + "name": company.name, + "inn": company.inn, + "contribution": "Updated contribution", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual( + ProjectCompany.objects.filter(project=self.project, company=company).count(), + 1, + ) + link = ProjectCompany.objects.get(project=self.project, company=company) + self.assertEqual(link.contribution, "Updated contribution") + + +class ProjectResourceRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + context = create_project_context( + user_prefix="project-resource-user", + draft=True, + ) + self.user = context.user + self.project = context.project + self.client.force_authenticate(self.user) + + def test_create_resource_without_partner_company(self): + response = self.client.post( + f"/projects/{self.project.id}/resources/", + { + "type": Resource.ResourceType.INFORMATION, + "description": "Need media coverage", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + resource = Resource.objects.get(pk=response.data["id"]) + self.assertEqual(resource.project, self.project) + self.assertIsNone(resource.partner_company) + + def test_create_resource_accepts_only_project_partner_company(self): + partner_company = create_company(inn="7701111111") + ProjectCompany.objects.create(project=self.project, company=partner_company) + + response = self.client.post( + f"/projects/{self.project.id}/resources/", + { + "type": Resource.ResourceType.INFRASTRUCTURE, + "description": "Need laboratory", + "partner_company": partner_company.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + resource = Resource.objects.get(pk=response.data["id"]) + self.assertEqual(resource.partner_company, partner_company) + + def test_create_resource_rejects_company_not_linked_to_project(self): + foreign_company = create_company(inn="7702222222") + + response = self.client.post( + f"/projects/{self.project.id}/resources/", + { + "type": Resource.ResourceType.INFRASTRUCTURE, + "description": "Need laboratory", + "partner_company": foreign_company.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("partner_company", response.data) + + def test_resource_helper_creates_valid_partner_link_when_company_is_given(self): + company = create_company(inn="7703333333") + + resource = create_resource(self.project, partner_company=company) + + self.assertEqual(resource.partner_company, company) + self.assertTrue( + ProjectCompany.objects.filter( + project=self.project, + company=company, + ).exists() + ) diff --git a/projects/tests/test_project_crud.py b/projects/tests/test_project_crud.py new file mode 100644 index 00000000..6ccf5a52 --- /dev/null +++ b/projects/tests/test_project_crud.py @@ -0,0 +1,202 @@ +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient + +from chats.models import ProjectChat +from projects.models import Collaborator, Project + +from .helpers import ( + add_program_member, + create_industry, + create_partner_program, + create_project, + create_project_context, + create_user, +) + + +class ProjectCreateAndReadAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = create_user(prefix="project-api-user") + self.client.force_authenticate(self.user) + self.industry = create_industry() + + def test_create_project_sets_current_user_as_leader_and_creates_collaborator(self): + response = self.client.post( + "/projects/", + { + "name": "Research platform", + "description": "Platform for applied research", + "industry": self.industry.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + project = Project.objects.get(pk=response.data["id"]) + self.assertEqual(project.leader, self.user) + self.assertEqual(response.data["leader"], self.user.id) + self.assertTrue( + Collaborator.objects.filter( + project=project, + user=self.user, + role="Основатель", + ).exists() + ) + + def test_create_published_project_creates_project_chat_and_shows_in_list(self): + response = self.client.post( + "/projects/", + { + "name": "Published research platform", + "description": "Visible project", + "industry": self.industry.id, + "draft": False, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + project = Project.objects.get(pk=response.data["id"]) + self.assertFalse(project.draft) + self.assertTrue(project.is_public) + self.assertTrue(ProjectChat.objects.filter(project=project).exists()) + + list_response = self.client.get("/projects/") + + self.assertEqual(list_response.status_code, 200) + listed_ids = [item["id"] for item in list_response.data["results"]] + self.assertIn(project.id, listed_ids) + + def test_draft_project_is_hidden_from_public_list(self): + draft_project = create_project( + leader=self.user, + name="Draft project", + draft=True, + ) + published_project = create_project( + leader=self.user, + name="Published project", + draft=False, + is_public=True, + ) + + response = self.client.get("/projects/") + + self.assertEqual(response.status_code, 200) + listed_ids = [item["id"] for item in response.data["results"]] + self.assertNotIn(draft_project.id, listed_ids) + self.assertIn(published_project.id, listed_ids) + + def test_project_detail_adds_view_for_authenticated_user(self): + project = create_project( + leader=self.user, + name="Detail project", + draft=False, + is_public=True, + ) + + response = self.client.get(f"/projects/{project.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], project.id) + self.assertEqual(response.data["name"], project.name) + self.assertEqual(response.data["views_count"], 1) + + +class ProjectPutUpdateAPITests(TestCase): + def setUp(self): + self.client = APIClient() + context = create_project_context( + user_prefix="project-put-leader", + project_name="Initial project", + draft=True, + ) + self.user = context.user + self.project = context.project + self.industry = create_industry(name="Updated industry") + self.client.force_authenticate(self.user) + + def _full_project_payload(self, **overrides): + payload = { + "name": "Updated project", + "description": "Updated project description", + "region": "Moscow", + "industry": self.industry.id, + "presentation_address": "https://example.com/presentation", + "image_address": "https://example.com/image.png", + "cover_image_address": "https://example.com/cover.png", + "draft": False, + "actuality": "Updated actuality", + "problem": "Updated problem", + "target_audience": "Updated target audience", + "implementation_deadline": "2026-12-31", + "trl": 5, + } + payload.update(overrides) + return payload + + def test_put_updates_full_project_form_without_changing_leader(self): + response = self.client.put( + f"/projects/{self.project.id}/", + self._full_project_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.project.refresh_from_db() + self.assertEqual(self.project.name, "Updated project") + self.assertEqual(self.project.description, "Updated project description") + self.assertEqual(self.project.region, "Moscow") + self.assertEqual(self.project.industry, self.industry) + self.assertEqual( + self.project.presentation_address, + "https://example.com/presentation", + ) + self.assertEqual(self.project.image_address, "https://example.com/image.png") + self.assertEqual( + self.project.cover_image_address, + "https://example.com/cover.png", + ) + self.assertFalse(self.project.draft) + self.assertEqual(self.project.actuality, "Updated actuality") + self.assertEqual(self.project.problem, "Updated problem") + self.assertEqual(self.project.target_audience, "Updated target audience") + self.assertEqual(str(self.project.implementation_deadline), "2026-12-31") + self.assertEqual(self.project.trl, 5) + self.assertEqual(self.project.leader, self.user) + + def test_put_binds_project_to_program_when_user_is_program_member(self): + program = create_partner_program(name="PUT target program") + member_profile = add_program_member(program, self.user) + + response = self.client.put( + f"/projects/{self.project.id}/", + self._full_project_payload(partner_program_id=program.id), + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.project.refresh_from_db() + member_profile.refresh_from_db() + self.assertTrue( + self.project.program_links.filter(partner_program=program).exists() + ) + self.assertEqual(member_profile.project, self.project) + self.assertFalse(self.project.is_public) + + def test_non_leader_cannot_put_update_project(self): + non_leader = create_user(prefix="project-put-non-leader") + self.client.force_authenticate(non_leader) + + response = self.client.put( + f"/projects/{self.project.id}/", + self._full_project_payload(), + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.project.refresh_from_db() + self.assertNotEqual(self.project.name, "Updated project") diff --git a/projects/tests/test_project_duplicate.py b/projects/tests/test_project_duplicate.py new file mode 100644 index 00000000..ca864cd2 --- /dev/null +++ b/projects/tests/test_project_duplicate.py @@ -0,0 +1,104 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject +from projects.models import Collaborator, Project + +from .helpers import ( + add_program_member, + create_collaborator, + create_partner_program, + create_project, + create_user, +) + + +class ProjectDuplicateToProgramRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="project-duplicate-user") + self.program = create_partner_program(name="Duplicate target program") + add_program_member(self.program, self.user) + self.client.force_authenticate(self.user) + + def test_duplicate_project_copies_data_collaborators_and_binds_to_program(self): + original_project = create_project( + leader=self.user, + name="Original project", + description="Original project description", + draft=False, + is_public=True, + ) + original_project.region = "Moscow" + original_project.actuality = "Important problem" + original_project.problem = "Known bottleneck" + original_project.target_audience = "Researchers" + original_project.trl = 5 + original_project.save() + + teammate = create_user(prefix="project-duplicate-teammate") + create_collaborator( + original_project, + user=teammate, + role="Engineer", + specialization="Backend", + ) + + response = self.client.post( + "/projects/assign-to-program/", + { + "project_id": original_project.id, + "partner_program_id": self.program.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + new_project = Project.objects.get(pk=response.data["new_project_id"]) + self.assertNotEqual(new_project.id, original_project.id) + self.assertEqual(new_project.name, original_project.name) + self.assertEqual(new_project.description, original_project.description) + self.assertEqual(new_project.region, original_project.region) + self.assertEqual(new_project.actuality, original_project.actuality) + self.assertEqual(new_project.problem, original_project.problem) + self.assertEqual(new_project.target_audience, original_project.target_audience) + self.assertEqual(new_project.trl, original_project.trl) + self.assertEqual(new_project.industry, original_project.industry) + self.assertEqual(new_project.leader, self.user) + self.assertTrue(new_project.draft) + self.assertFalse(new_project.is_public) + self.assertTrue( + PartnerProgramProject.objects.filter( + project=new_project, + partner_program=self.program, + ).exists() + ) + self.assertTrue( + Collaborator.objects.filter( + project=new_project, + user=teammate, + role="Engineer", + specialization="Backend", + ).exists() + ) + + def test_non_leader_cannot_duplicate_project_to_program(self): + other_leader = create_user(prefix="project-duplicate-other-leader") + original_project = create_project(leader=other_leader) + + response = self.client.post( + "/projects/assign-to-program/", + { + "project_id": original_project.id, + "partner_program_id": self.program.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse( + PartnerProgramProject.objects.filter( + project__name=original_project.name, + partner_program=self.program, + ).exists() + ) diff --git a/projects/tests/test_project_filters.py b/projects/tests/test_project_filters.py new file mode 100644 index 00000000..de10e8fa --- /dev/null +++ b/projects/tests/test_project_filters.py @@ -0,0 +1,110 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from .helpers import ( + add_program_member, + create_collaborator, + create_industry, + create_partner_program, + create_project, + create_user, + link_project_to_program, +) + + +class ProjectListFilterRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="project-filter-user") + self.client.force_authenticate(self.user) + + def _project_ids(self, response): + self.assertEqual(response.status_code, 200) + return {project["id"] for project in response.data["results"]} + + def test_filter_by_industry(self): + target_industry = create_industry(name="Target industry") + other_industry = create_industry(name="Other industry") + target_project = create_project( + leader=self.user, + industry=target_industry, + draft=False, + is_public=True, + ) + other_project = create_project( + leader=self.user, + industry=other_industry, + draft=False, + is_public=True, + ) + + response = self.client.get("/projects/", {"industry": target_industry.id}) + + project_ids = self._project_ids(response) + self.assertIn(target_project.id, project_ids) + self.assertNotIn(other_project.id, project_ids) + + def test_filter_by_leader(self): + other_leader = create_user(prefix="project-filter-other-leader") + target_project = create_project( + leader=self.user, + draft=False, + is_public=True, + ) + other_project = create_project( + leader=other_leader, + draft=False, + is_public=True, + ) + + response = self.client.get("/projects/", {"leader": self.user.id}) + + project_ids = self._project_ids(response) + self.assertIn(target_project.id, project_ids) + self.assertNotIn(other_project.id, project_ids) + + def test_filter_by_partner_program(self): + target_program = create_partner_program(name="Target program") + other_program = create_partner_program(name="Other program") + add_program_member(target_program, self.user) + add_program_member(other_program, self.user) + target_project = create_project(draft=False, is_public=True) + other_project = create_project(draft=False, is_public=True) + link_project_to_program(target_project, target_program) + link_project_to_program(other_project, other_program) + + response = self.client.get( + "/projects/", + {"partner_program": target_program.id}, + ) + + project_ids = self._project_ids(response) + self.assertIn(target_project.id, project_ids) + self.assertNotIn(other_project.id, project_ids) + + def test_filter_by_is_company(self): + company_project = create_project(draft=False, is_public=True) + company_project.is_company = True + company_project.save(update_fields=["is_company"]) + regular_project = create_project(draft=False, is_public=True) + + response = self.client.get("/projects/", {"is_company": "true"}) + + project_ids = self._project_ids(response) + self.assertIn(company_project.id, project_ids) + self.assertNotIn(regular_project.id, project_ids) + + def test_filter_by_collaborator_user_in(self): + collaborator = create_user(prefix="project-filter-collaborator") + target_project = create_project(draft=False, is_public=True) + other_project = create_project(draft=False, is_public=True) + create_collaborator(target_project, user=collaborator) + + response = self.client.get( + "/projects/", + {"collaborator__user__in": str(collaborator.id)}, + ) + + project_ids = self._project_ids(response) + self.assertIn(target_project.id, project_ids) + self.assertNotIn(other_project.id, project_ids) diff --git a/projects/tests/test_project_goals.py b/projects/tests/test_project_goals.py new file mode 100644 index 00000000..299ae7a4 --- /dev/null +++ b/projects/tests/test_project_goals.py @@ -0,0 +1,90 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from projects.models import ProjectGoal + +from .helpers import ( + create_project_context, + create_project_goal, + create_user, +) + + +class ProjectGoalRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + context = create_project_context( + user_prefix="project-goal-leader", + draft=True, + ) + self.user = context.user + self.project = context.project + self.client.force_authenticate(self.user) + + def test_leader_can_create_goals_list(self): + response = self.client.post( + f"/projects/{self.project.id}/goals/", + [ + { + "title": "Prepare prototype", + "responsible": self.user.id, + }, + { + "title": "Run pilot", + "responsible": self.user.id, + }, + ], + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(ProjectGoal.objects.filter(project=self.project).count(), 2) + self.assertEqual( + list( + ProjectGoal.objects.filter(project=self.project) + .order_by("title") + .values_list("title", flat=True) + ), + [ + "Prepare prototype", + "Run pilot", + ], + ) + + def test_leader_can_partially_update_single_goal(self): + goal = create_project_goal( + self.project, + responsible=self.user, + title="Prepare prototype", + ) + + response = self.client.patch( + f"/projects/{self.project.id}/goals/{goal.id}/", + { + "is_done": True, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + goal.refresh_from_db() + self.assertTrue(goal.is_done) + self.assertEqual(goal.project, self.project) + + def test_non_leader_cannot_create_project_goals(self): + non_leader = create_user(prefix="project-goal-non-leader") + self.client.force_authenticate(non_leader) + + response = self.client.post( + f"/projects/{self.project.id}/goals/", + [ + { + "title": "Prepare prototype", + "responsible": non_leader.id, + }, + ], + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse(ProjectGoal.objects.filter(project=self.project).exists()) diff --git a/projects/tests/test_project_news.py b/projects/tests/test_project_news.py new file mode 100644 index 00000000..ff6ec829 --- /dev/null +++ b/projects/tests/test_project_news.py @@ -0,0 +1,155 @@ +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient + +from core.models import Like, View +from news.models import News +from projects.models import ProjectNews + +from .helpers import create_project, create_project_context, create_user + + +class ProjectNewsRegressionTests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + context = create_project_context( + user_prefix="project-news-leader", + draft=False, + is_public=True, + ) + self.leader = context.user + self.project = context.project + self.client.force_authenticate(self.leader) + + def _content_type(self): + return ContentType.objects.get_for_model(News) + + def test_leader_can_create_project_news(self): + response = self.client.post( + f"/projects/{self.project.id}/news/", + { + "text": "Project news text", + "files": [], + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + news = News.objects.get(pk=response.data["id"]) + self.assertEqual(news.content_object, self.project) + self.assertEqual(news.text, "Project news text") + self.assertFalse(ProjectNews.objects.exists()) + + def test_project_news_list_returns_only_visible_news_for_current_project(self): + visible_news = News.objects.add_news(self.project, text="Visible project news") + News.objects.add_news(self.project, text="") + other_project = create_project(draft=False, is_public=True) + other_news = News.objects.add_news(other_project, text="Other project news") + + response = self.client.get(f"/projects/{self.project.id}/news/") + + self.assertEqual(response.status_code, 200) + news_ids = {item["id"] for item in response.data["results"]} + self.assertIn(visible_news.id, news_ids) + self.assertNotIn(other_news.id, news_ids) + + def test_project_news_detail_is_available_for_modal_view(self): + news = News.objects.add_news(self.project, text="Modal project news") + + response = self.client.get(f"/projects/{self.project.id}/news/{news.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], news.id) + self.assertEqual(response.data["text"], "Modal project news") + + def test_project_news_can_be_marked_viewed_and_liked(self): + news = News.objects.add_news(self.project, text="Interactive project news") + content_type = self._content_type() + + viewed_response = self.client.post( + f"/projects/{self.project.id}/news/{news.id}/set_viewed/", + {"is_viewed": True}, + format="json", + ) + liked_response = self.client.post( + f"/projects/{self.project.id}/news/{news.id}/set_liked/", + {"is_liked": True}, + format="json", + ) + + self.assertEqual(viewed_response.status_code, 200) + self.assertEqual(liked_response.status_code, 200) + self.assertTrue( + View.objects.filter( + content_type=content_type, + object_id=news.id, + user=self.leader, + ).exists() + ) + self.assertTrue( + Like.objects.filter( + content_type=content_type, + object_id=news.id, + user=self.leader, + ).exists() + ) + + unliked_response = self.client.post( + f"/projects/{self.project.id}/news/{news.id}/set_liked/", + {"is_liked": False}, + format="json", + ) + + self.assertEqual(unliked_response.status_code, 200) + self.assertFalse( + Like.objects.filter( + content_type=content_type, + object_id=news.id, + user=self.leader, + ).exists() + ) + + def test_project_leader_can_edit_and_delete_project_news(self): + news = News.objects.add_news(self.project, text="Initial project news") + + patch_response = self.client.patch( + f"/projects/{self.project.id}/news/{news.id}/", + { + "text": "Updated project news", + "files": [], + }, + format="json", + ) + + self.assertEqual(patch_response.status_code, 200) + news.refresh_from_db() + self.assertEqual(news.text, "Updated project news") + + delete_response = self.client.delete( + f"/projects/{self.project.id}/news/{news.id}/" + ) + + self.assertEqual(delete_response.status_code, 204) + self.assertFalse(News.objects.filter(pk=news.id).exists()) + + def test_non_leader_cannot_create_project_news(self): + non_leader = create_user(prefix="project-news-non-leader") + self.client.force_authenticate(non_leader) + + response = self.client.post( + f"/projects/{self.project.id}/news/", + { + "text": "Forbidden project news", + "files": [], + }, + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse( + News.objects.get_news(self.project) + .filter(text="Forbidden project news") + .exists() + ) diff --git a/projects/tests/test_project_programs.py b/projects/tests/test_project_programs.py new file mode 100644 index 00000000..fd3e43ef --- /dev/null +++ b/projects/tests/test_project_programs.py @@ -0,0 +1,71 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile + +from .helpers import ( + add_program_member, + create_industry, + create_partner_program, + create_user, +) + + +class ProjectProgramBindingRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = create_user(prefix="project-program-user") + self.industry = create_industry() + self.client.force_authenticate(self.user) + + def test_program_member_can_create_project_bound_to_program(self): + program = create_partner_program() + member_profile = add_program_member(program, self.user) + + response = self.client.post( + "/projects/", + { + "name": "Program project", + "description": "Project for partner program", + "industry": self.industry.id, + "partner_program_id": program.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + project_id = response.data["id"] + self.assertTrue( + PartnerProgramProject.objects.filter( + project_id=project_id, + partner_program=program, + ).exists() + ) + member_profile.refresh_from_db() + self.assertEqual(member_profile.project_id, project_id) + self.assertFalse(member_profile.project.is_public) + + def test_user_cannot_create_project_for_program_without_membership(self): + program = create_partner_program() + + response = self.client.post( + "/projects/", + { + "name": "Foreign program project", + "description": "Project for unavailable partner program", + "industry": self.industry.id, + "partner_program_id": program.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 403) + self.assertFalse( + PartnerProgramUserProfile.objects.filter( + user=self.user, + partner_program=program, + ).exists() + ) + self.assertFalse( + PartnerProgramProject.objects.filter(partner_program=program).exists() + ) diff --git a/projects/tests/test_project_signals.py b/projects/tests/test_project_signals.py new file mode 100644 index 00000000..a0319d25 --- /dev/null +++ b/projects/tests/test_project_signals.py @@ -0,0 +1,44 @@ +from django.test import TestCase + +from chats.models import ProjectChat +from news.models import News + +from .helpers import create_project, create_vacancy + + +class ProjectDraftSignalRegressionTests(TestCase): + def test_publish_project_activates_vacancies_creates_feed_news_and_chat(self): + project = create_project(draft=True) + vacancy = create_vacancy(project, is_active=False) + + project.draft = False + project.save() + + vacancy.refresh_from_db() + self.assertTrue(vacancy.is_active) + self.assertTrue(News.objects.get_news(vacancy).filter(text="").exists()) + self.assertTrue(ProjectChat.objects.filter(project=project).exists()) + + def test_return_project_to_draft_deactivates_vacancies_and_removes_feed_news(self): + project = create_project(draft=False) + vacancy = create_vacancy(project, is_active=True) + + self.assertTrue(News.objects.get_news(vacancy).filter(text="").exists()) + + project.draft = True + project.save() + + vacancy.refresh_from_db() + self.assertFalse(vacancy.is_active) + self.assertFalse(News.objects.get_news(vacancy).filter(text="").exists()) + + def test_repeated_publish_does_not_duplicate_feed_news_or_chat(self): + project = create_project(draft=True) + vacancy = create_vacancy(project, is_active=False) + + project.draft = False + project.save() + project.save() + + self.assertEqual(News.objects.get_news(vacancy).filter(text="").count(), 1) + self.assertEqual(ProjectChat.objects.filter(project=project).count(), 1) diff --git a/projects/tests/test_project_subscriptions.py b/projects/tests/test_project_subscriptions.py new file mode 100644 index 00000000..63c67bae --- /dev/null +++ b/projects/tests/test_project_subscriptions.py @@ -0,0 +1,36 @@ +from django.test import TestCase +from rest_framework.test import APIClient + +from .helpers import create_project, create_user + + +class ProjectSubscriptionRegressionTests(TestCase): + def setUp(self): + self.client = APIClient() + self.project = create_project(draft=False, is_public=True) + self.user = create_user(prefix="project-subscriber") + self.client.force_authenticate(self.user) + + def test_user_can_subscribe_to_public_project(self): + response = self.client.post(f"/projects/{self.project.id}/subscribe/") + + self.assertEqual(response.status_code, 200) + self.assertTrue(self.project.subscribers.filter(pk=self.user.id).exists()) + + def test_user_can_unsubscribe_from_project(self): + self.project.subscribers.add(self.user) + + response = self.client.post(f"/projects/{self.project.id}/unsubscribe/") + + self.assertEqual(response.status_code, 200) + self.assertFalse(self.project.subscribers.filter(pk=self.user.id).exists()) + + def test_subscribers_list_returns_project_subscribers(self): + subscriber = create_user(prefix="project-subscriber-list") + self.project.subscribers.add(subscriber) + + response = self.client.get(f"/projects/{self.project.id}/subscribers/") + + self.assertEqual(response.status_code, 200) + subscriber_ids = [item["id"] for item in response.data] + self.assertIn(subscriber.id, subscriber_ids)